mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-25 23:16:17 +01:00
Compare commits
78 Commits
v0.12.0-be
...
encoding/j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47360c5bf1 | ||
|
|
7ba1f366ba | ||
|
|
37c6b920f9 | ||
|
|
49db81dac8 | ||
|
|
a9e90ec19c | ||
|
|
2ad60507b7 | ||
|
|
12059ee3db | ||
|
|
de56544ca3 | ||
|
|
065c7facb6 | ||
|
|
630c92c139 | ||
|
|
e11d452d91 | ||
|
|
99c7f7bd8a | ||
|
|
8af3a0eb5b | ||
|
|
5f7950b474 | ||
|
|
df9e2dec28 | ||
|
|
a0f271545a | ||
|
|
aa2bc9f118 | ||
|
|
b22ae87022 | ||
|
|
79e79079bc | ||
|
|
1811ebdee4 | ||
|
|
137f3f3e24 | ||
|
|
ed1d1e77c0 | ||
|
|
8c36dd1caa | ||
|
|
57bfe72486 | ||
|
|
75f66b0246 | ||
|
|
ce93d54aa7 | ||
|
|
39dbe0eac5 | ||
|
|
7282044f80 | ||
|
|
d77c37c0b0 | ||
|
|
e362cbbca5 | ||
|
|
118544926b | ||
|
|
d4bb0a0a30 | ||
|
|
fe5e35d1a9 | ||
|
|
60a6ae2caa | ||
|
|
80338d36aa | ||
|
|
f0d2c242e8 | ||
|
|
559f83d99c | ||
|
|
d3a751ee6c | ||
|
|
fb70a166fa | ||
|
|
c12457b707 | ||
|
|
3e53d73d56 | ||
|
|
80338c5e98 | ||
|
|
249cd8ad19 | ||
|
|
ccdff46370 | ||
|
|
91679b5cc0 | ||
|
|
6953edf59e | ||
|
|
b91c77ec92 | ||
|
|
3ac0b185d1 | ||
|
|
1e675cabb5 | ||
|
|
5f44965c2c | ||
|
|
f080929296 | ||
|
|
f055658eba | ||
|
|
e430c747fe | ||
|
|
ca62b1db36 | ||
|
|
38569b7057 | ||
|
|
203244090f | ||
|
|
2bed722045 | ||
|
|
13f3a52760 | ||
|
|
16b9827c70 | ||
|
|
0fc352d7fc | ||
|
|
8a2bee11d4 | ||
|
|
485f7d16ff | ||
|
|
46fdc94cb8 | ||
|
|
261f7fb76c | ||
|
|
18d9258907 | ||
|
|
9d7fb8ab80 | ||
|
|
3730a78e5a | ||
|
|
7cdd0907e8 | ||
|
|
3586f73f30 | ||
|
|
752ccc6beb | ||
|
|
f577476c81 | ||
|
|
49ae424698 | ||
|
|
d4fd19522b | ||
|
|
5c047e4afd | ||
|
|
6576141f54 | ||
|
|
926e807020 | ||
|
|
d91847c6c5 | ||
|
|
0abd88270c |
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,8 +1,19 @@
|
|||||||
name: 🐛 Bug report
|
name: 🐛 Bug report
|
||||||
description: Report a new bug or issue.
|
description: Report a new bug or issue.
|
||||||
title: '[Bug]: '
|
title: '[Bug]: '
|
||||||
labels: ['bug']
|
labels: ['bug', "needs confirmation"]
|
||||||
body:
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of Beszel is this about?
|
||||||
|
options:
|
||||||
|
- Hub
|
||||||
|
- Agent
|
||||||
|
- Hub & Agent
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
@@ -43,6 +54,39 @@ body:
|
|||||||
3. Pour it into a cup.
|
3. Pour it into a cup.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: Which category does this relate to most?
|
||||||
|
options:
|
||||||
|
- Metrics
|
||||||
|
- Charts & Visualization
|
||||||
|
- Settings & Configuration
|
||||||
|
- Notifications & Alerts
|
||||||
|
- Authentication
|
||||||
|
- Installation
|
||||||
|
- Performance
|
||||||
|
- UI / UX
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: metrics
|
||||||
|
attributes:
|
||||||
|
label: Affected Metrics
|
||||||
|
description: If applicable, which specific metric does this relate to most?
|
||||||
|
options:
|
||||||
|
- CPU
|
||||||
|
- Memory
|
||||||
|
- Storage
|
||||||
|
- Network
|
||||||
|
- Containers
|
||||||
|
- GPU
|
||||||
|
- Sensors
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
id: system
|
id: system
|
||||||
attributes:
|
attributes:
|
||||||
@@ -61,7 +105,6 @@ body:
|
|||||||
id: install-method
|
id: install-method
|
||||||
attributes:
|
attributes:
|
||||||
label: Installation method
|
label: Installation method
|
||||||
default: 0
|
|
||||||
options:
|
options:
|
||||||
- Docker
|
- Docker
|
||||||
- Binary
|
- Binary
|
||||||
|
|||||||
60
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
60
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,8 +1,19 @@
|
|||||||
name: 🚀 Feature request
|
name: 🚀 Feature request
|
||||||
description: Request a new feature or change.
|
description: Request a new feature or change.
|
||||||
title: "[Feature]: "
|
title: "[Feature]: "
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement", "needs review"]
|
||||||
body:
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of Beszel is this about?
|
||||||
|
options:
|
||||||
|
- Hub
|
||||||
|
- Agent
|
||||||
|
- Hub & Agent
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||||
@@ -11,8 +22,55 @@ body:
|
|||||||
label: Describe the feature you would like to see
|
label: Describe the feature you would like to see
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: Motivation / Use Case
|
||||||
|
description: Why do you want this feature? What problem does it solve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe how you would like to see this feature implemented
|
label: Describe how you would like to see this feature implemented
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: Which category does this relate to most?
|
||||||
|
options:
|
||||||
|
- Metrics
|
||||||
|
- Charts & Visualization
|
||||||
|
- Settings & Configuration
|
||||||
|
- Notifications & Alerts
|
||||||
|
- Authentication
|
||||||
|
- Installation
|
||||||
|
- Performance
|
||||||
|
- UI / UX
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: metrics
|
||||||
|
attributes:
|
||||||
|
label: Affected Metrics
|
||||||
|
description: If applicable, which specific metric does this relate to most?
|
||||||
|
options:
|
||||||
|
- CPU
|
||||||
|
- Memory
|
||||||
|
- Storage
|
||||||
|
- Network
|
||||||
|
- Containers
|
||||||
|
- GPU
|
||||||
|
- Sensors
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
33
.github/pull_request_template.md
vendored
Normal file
33
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
## 📃 Description
|
||||||
|
|
||||||
|
A short description of the pull request changes should go here and the sections below should list in detail all changes. You can remove the sections you don't need.
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
Add a link to the PR for [documentation](https://github.com/henrygd/beszel-docs) changes.
|
||||||
|
|
||||||
|
## 🪵 Changelog
|
||||||
|
|
||||||
|
### ➕ Added
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
### ✏️ Changed
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
### 🔧 Fixed
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
### 🗑️ Removed
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
## 📷 Screenshots
|
||||||
|
|
||||||
|
If this PR has any UI/UX changes it's strongly suggested you add screenshots here.
|
||||||
32
.github/workflows/docker-images.yml
vendored
32
.github/workflows/docker-images.yml
vendored
@@ -14,28 +14,48 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
|
- image: henrygd/beszel-agent-nvidia
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
|
platforms: linux/amd64
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
|
platforms: linux/amd64
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -65,7 +85,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: |
|
tags: |
|
||||||
type=edge,enable=true
|
type=raw,value=edge
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -87,7 +107,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: "${{ matrix.context }}"
|
context: "${{ matrix.context }}"
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||||
push: ${{ github.ref_type == 'tag' }}
|
push: ${{ github.ref_type == 'tag' }}
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
|||||||
43
.github/workflows/inactivity-actions.yml
vendored
Normal file
43
.github/workflows/inactivity-actions.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: 'Issue and PR Maintenance'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # runs at midnight UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-stale:
|
||||||
|
name: Close Stale Issues
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Close Stale Issues
|
||||||
|
uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Messaging
|
||||||
|
stale-issue-message: >
|
||||||
|
👋 This issue has been automatically marked as stale due to inactivity.
|
||||||
|
If this issue is still relevant, please comment to keep it open.
|
||||||
|
Without activity, it will be closed in 7 days.
|
||||||
|
|
||||||
|
close-issue-message: >
|
||||||
|
🔒 This issue has been automatically closed due to prolonged inactivity.
|
||||||
|
Feel free to open a new issue if you have further questions or concerns.
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
days-before-issue-stale: 14
|
||||||
|
days-before-issue-close: 7
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
stale-issue-label: 'stale'
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
only-issue-labels: 'awaiting-requester'
|
||||||
|
|
||||||
|
# Exemptions
|
||||||
|
exempt-assignees: true
|
||||||
|
exempt-milestones: true
|
||||||
82
.github/workflows/label-from-dropdown.yml
vendored
Normal file
82
.github/workflows/label-from-dropdown.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Label issues from dropdowns
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label_from_dropdown:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Apply labels based on dropdown choices
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
|
||||||
|
const issueNumber = context.issue.number;
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
|
||||||
|
// Get the issue body
|
||||||
|
const body = context.payload.issue.body;
|
||||||
|
|
||||||
|
// Helper to find dropdown value in the body (assuming markdown format)
|
||||||
|
function extractSectionValue(heading) {
|
||||||
|
const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i');
|
||||||
|
const match = body.match(regex);
|
||||||
|
if (match) {
|
||||||
|
// Get the first non-empty line after the heading
|
||||||
|
const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
return lines[0] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract dropdown selections
|
||||||
|
const category = extractSectionValue('Category');
|
||||||
|
const metrics = extractSectionValue('Affected Metrics');
|
||||||
|
const component = extractSectionValue('Component');
|
||||||
|
|
||||||
|
// Build labels to add
|
||||||
|
let labelsToAdd = [];
|
||||||
|
if (category) labelsToAdd.push(category);
|
||||||
|
if (metrics) labelsToAdd.push(metrics);
|
||||||
|
if (component) labelsToAdd.push(component);
|
||||||
|
|
||||||
|
// Get existing labels in the repo
|
||||||
|
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
const existingLabelNames = existingLabels.map(l => l.name);
|
||||||
|
|
||||||
|
// Find labels that need to be created
|
||||||
|
const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label));
|
||||||
|
|
||||||
|
// Create missing labels (with a default color)
|
||||||
|
for (const label of labelsToCreate) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
name: label,
|
||||||
|
color: 'ededed' // light gray, you can pick any hex color
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore if label already exists (race condition), otherwise rethrow
|
||||||
|
if (!e || e.status !== 422) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now apply all labels (they all exist now)
|
||||||
|
if (labelsToAdd.length > 0) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
labels: labelsToAdd
|
||||||
|
});
|
||||||
|
}
|
||||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Make release and binaries
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -29,7 +29,17 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '^1.22.1'
|
go-version: "^1.22.1"
|
||||||
|
|
||||||
|
- name: Set up .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "9.0.x"
|
||||||
|
|
||||||
|
- name: Build .NET LHM executable for Windows sensors
|
||||||
|
run: |
|
||||||
|
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: GoReleaser beszel
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
@@ -40,3 +50,4 @@ jobs:
|
|||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ beszel/build
|
|||||||
beszel/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
|
beszel/internal/agent/lhm/obj
|
||||||
|
beszel/internal/agent/lhm/bin
|
||||||
|
dockerfile_agent_dev
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ brews:
|
|||||||
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
keep_alive true
|
keep_alive true
|
||||||
restart_delay 5
|
restart_delay 5
|
||||||
name beszel-agent
|
|
||||||
process_type :background
|
process_type :background
|
||||||
|
|
||||||
winget:
|
winget:
|
||||||
@@ -203,13 +202,14 @@ winget:
|
|||||||
owner: henrygd
|
owner: henrygd
|
||||||
name: beszel-winget
|
name: beszel-winget
|
||||||
branch: henrygd.beszel-agent-{{ .Version }}
|
branch: henrygd.beszel-agent-{{ .Version }}
|
||||||
pull_request:
|
token: "{{ .Env.WINGET_TOKEN }}"
|
||||||
enabled: false
|
# pull_request:
|
||||||
draft: false
|
# enabled: true
|
||||||
base:
|
# draft: false
|
||||||
owner: microsoft
|
# base:
|
||||||
name: winget-pkgs
|
# owner: microsoft
|
||||||
branch: master
|
# name: winget-pkgs
|
||||||
|
# branch: master
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ ARCH ?= $(shell go env GOARCH)
|
|||||||
# Skip building the web UI if true
|
# Skip building the web UI if true
|
||||||
SKIP_WEB ?= false
|
SKIP_WEB ?= false
|
||||||
|
|
||||||
|
# Set executable extension based on target OS
|
||||||
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
@@ -14,7 +17,7 @@ clean:
|
|||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
test: export GOEXPERIMENT=synctest
|
test: export GOEXPERIMENT=synctest,jsonv2
|
||||||
test:
|
test:
|
||||||
go test -tags=testing ./...
|
go test -tags=testing ./...
|
||||||
|
|
||||||
@@ -30,11 +33,25 @@ build-web-ui:
|
|||||||
npm run --prefix ./site build; \
|
npm run --prefix ./site build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-agent: tidy
|
# Conditional .NET build - only for Windows
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
build-dotnet-conditional:
|
||||||
|
@if [ "$(OS)" = "windows" ]; then \
|
||||||
|
echo "Building .NET executable for Windows..."; \
|
||||||
|
if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update build-agent to include conditional .NET build
|
||||||
|
build-agent: tidy build-dotnet-conditional
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build: build-agent build-hub
|
build: build-agent build-hub
|
||||||
|
|
||||||
@@ -53,14 +70,16 @@ dev-server: generate-locales
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
dev-hub: export ENV=dev
|
dev-hub: export ENV=dev
|
||||||
|
dev-hub: export GOEXPERIMENT=jsonv2
|
||||||
dev-hub:
|
dev-hub:
|
||||||
mkdir -p ./site/dist && touch ./site/dist/index.html
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./cmd/hub ./internal/{alerts,hub,records,users} -name "*.go" | entr -r -s "cd ./cmd/hub && go run . serve"; \
|
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
|
||||||
else \
|
else \
|
||||||
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
dev-agent: export GOEXPERIMENT=jsonv2
|
||||||
dev-agent:
|
dev-agent:
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
||||||
@@ -68,5 +87,14 @@ dev-agent:
|
|||||||
go run beszel/cmd/agent; \
|
go run beszel/cmd/agent; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build-dotnet:
|
||||||
|
@if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "dotnet not found"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# KEY="..." make -j dev
|
# KEY="..." make -j dev
|
||||||
dev: dev-server dev-hub dev-agent
|
dev: dev-server dev-hub dev-agent
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -25,13 +26,16 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0])
|
builder := strings.Builder{}
|
||||||
fmt.Println("\nCommands:")
|
builder.WriteString("Usage: ")
|
||||||
fmt.Println(" health Check if the agent is running")
|
builder.WriteString(os.Args[0])
|
||||||
fmt.Println(" help Display this help message")
|
builder.WriteString(" [command] [flags]\n")
|
||||||
fmt.Println(" update Update to the latest version")
|
builder.WriteString("\nCommands:\n")
|
||||||
fmt.Println(" version Display the version")
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
fmt.Println("\nFlags:")
|
builder.WriteString(" help Display this help message\n")
|
||||||
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
|
builder.WriteString("\nFlags:\n")
|
||||||
|
fmt.Print(builder.String())
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +115,12 @@ func main() {
|
|||||||
serverConfig.Addr = addr
|
serverConfig.Addr = addr
|
||||||
serverConfig.Network = agent.GetNetwork(addr)
|
serverConfig.Network = agent.GetNetwork(addr)
|
||||||
|
|
||||||
agent, err := agent.NewAgent("")
|
a, err := agent.NewAgent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to create agent: ", err)
|
log.Fatal("Failed to create agent: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := agent.Start(serverConfig); err != nil {
|
if err := a.Start(serverConfig); err != nil {
|
||||||
log.Fatal("Failed to start server: ", err)
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
# RUN go mod download
|
|
||||||
COPY *.go ./
|
|
||||||
COPY cmd ./cmd
|
|
||||||
COPY internal ./internal
|
|
||||||
|
|
||||||
# Build
|
|
||||||
ARG TARGETOS TARGETARCH
|
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
|
||||||
|
|
||||||
# ? -------------------------
|
|
||||||
FROM scratch
|
|
||||||
|
|
||||||
COPY --from=builder /agent /agent
|
|
||||||
|
|
||||||
# this is so we don't need to create the
|
|
||||||
# /tmp directory in the scratch container
|
|
||||||
COPY --from=builder /tmp /tmp
|
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
|
||||||
26
beszel/dockerfile_agent
Normal file
26
beszel/dockerfile_agent
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ARG TARGETOS TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOGC=75 GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# Final image: default scratch-based agent
|
||||||
|
# --------------------------
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/agent"]
|
||||||
21
beszel/dockerfile_agent_nvidia
Normal file
21
beszel/dockerfile_agent_nvidia
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ARG TARGETOS TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOGC=75 GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
|
# --------------------------
|
||||||
|
FROM nvidia/cuda:12.9.1-base-ubuntu22.04
|
||||||
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -22,7 +22,7 @@ RUN update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
RUN CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -7,20 +7,20 @@ replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.8.15
|
github.com/nicholas-fedor/shoutrrr v0.8.17
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.28.4
|
github.com/pocketbase/pocketbase v0.29.2
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6
|
github.com/shirou/gopsutil/v4 v4.25.7
|
||||||
github.com/spf13/cast v1.9.2
|
github.com/spf13/cast v1.9.2
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ require (
|
|||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||||
@@ -52,21 +52,21 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.7 // indirect
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.13 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.28.0 // indirect
|
golang.org/x/image v0.30.0 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
modernc.org/libc v1.65.10 // indirect
|
modernc.org/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.38.0 // indirect
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
|
|||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
@@ -46,8 +46,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -103,8 +103,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.28.4 h1:RmhWXDcfKrFM9/W0G0Zrlv4eKBM8/s/v4SQKytjgD20=
|
github.com/pocketbase/pocketbase v0.29.2 h1:MghVgLYy/xh9lBwHtteNSYjYOvHKYD+dS9pzUzOP79Q=
|
||||||
github.com/pocketbase/pocketbase v0.28.4/go.mod h1:jSuN93vE/oeJVOz2D2ZxcYyr2bYNmDOMCUkM+JhyJQ0=
|
github.com/pocketbase/pocketbase v0.29.2/go.mod h1:QZPKtMCWfiDJb0aLhwgj7ZOr6O8tusbui2EhTFAHThU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -114,14 +114,15 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
|
||||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||||
|
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
@@ -133,8 +134,8 @@ github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nE
|
|||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
@@ -143,29 +144,29 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
|||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -173,19 +174,19 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -201,16 +202,22 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||||
|
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
|
||||||
|
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
|
||||||
|
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -219,8 +226,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ type Agent struct {
|
|||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
// If the data directory is not set, it will attempt to find the optimal directory.
|
// If the data directory is not set, it will attempt to find the optimal directory.
|
||||||
func NewAgent(dataDir string) (agent *Agent, err error) {
|
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||||
agent = &Agent{
|
agent = &Agent{
|
||||||
fsStats: make(map[string]*system.FsStats),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
cache: NewSessionCache(69 * time.Second),
|
cache: NewSessionCache(69 * time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir)
|
agent.dataDir, err = getDataDir(dataDir...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -113,37 +113,37 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
cachedData, ok := a.cache.Get(sessionID)
|
data, isCached := a.cache.Get(sessionID)
|
||||||
if ok {
|
if isCached {
|
||||||
slog.Debug("Cached stats", "session", sessionID)
|
slog.Debug("Cached data", "session", sessionID)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
*cachedData = system.CombinedData{
|
*data = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", cachedData)
|
slog.Debug("System data", "data", data)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
cachedData.Containers = containerStats
|
data.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", cachedData.Containers)
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Docker stats", "err", err)
|
slog.Debug("Containers", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
cachedData.Stats.ExtraFs[name] = stats
|
data.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(sessionID, cachedData)
|
a.cache.Set(sessionID, data)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -53,9 +54,9 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
return nil, errors.New("invalid hub URL")
|
return nil, errors.New("invalid hub URL")
|
||||||
}
|
}
|
||||||
// get registration token
|
// get registration token
|
||||||
client.token, _ = GetEnv("TOKEN")
|
client.token, err = getToken()
|
||||||
if client.token == "" {
|
if err != nil {
|
||||||
return nil, errors.New("TOKEN environment variable not set")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client.agent = agent
|
client.agent = agent
|
||||||
@@ -65,6 +66,27 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getToken returns the token for the WebSocket client.
|
||||||
|
// It first checks the TOKEN environment variable, then the TOKEN_FILE environment variable.
|
||||||
|
// If neither is set, it returns an error.
|
||||||
|
func getToken() (string, error) {
|
||||||
|
// get token from env var
|
||||||
|
token, _ := GetEnv("TOKEN")
|
||||||
|
if token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
// get token from file
|
||||||
|
tokenFile, _ := GetEnv("TOKEN_FILE")
|
||||||
|
if tokenFile == "" {
|
||||||
|
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||||
|
}
|
||||||
|
tokenBytes, err := os.ReadFile(tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(tokenBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
// It configures the connection URL, TLS settings, and authentication headers.
|
// It configures the connection URL, TLS settings, and authentication headers.
|
||||||
func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||||
|
|||||||
538
beszel/internal/agent/client_test.go
Normal file
538
beszel/internal/agent/client_test.go
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
hubURL string
|
||||||
|
token string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid configuration",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "test-token-123",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid https URL",
|
||||||
|
hubURL: "https://hub.example.com",
|
||||||
|
token: "secure-token",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hub URL",
|
||||||
|
hubURL: "",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "HUB_URL environment variable not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
hubURL: "ht\ttp://invalid",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid hub URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "must set TOKEN or TOKEN_FILE",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
if tc.hubURL != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
}
|
||||||
|
if tc.token != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
assert.Nil(t, client)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, client)
|
||||||
|
assert.Equal(t, agent, client.agent)
|
||||||
|
assert.Equal(t, tc.token, client.token)
|
||||||
|
assert.Equal(t, tc.hubURL, client.hubURL.String())
|
||||||
|
assert.NotEmpty(t, client.fingerprint)
|
||||||
|
assert.NotNil(t, client.hubRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetOptions tests WebSocket client options configuration
|
||||||
|
func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
inputURL string
|
||||||
|
expectedScheme string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http to ws conversion",
|
||||||
|
inputURL: "http://localhost:8080",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https to wss conversion",
|
||||||
|
inputURL: "https://hub.example.com",
|
||||||
|
expectedScheme: "wss",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing path preservation",
|
||||||
|
inputURL: "http://localhost:8080/custom/path",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/custom/path/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
options := client.getOptions()
|
||||||
|
|
||||||
|
// Parse the WebSocket URL
|
||||||
|
wsURL, err := url.Parse(options.Addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
|
||||||
|
assert.Equal(t, tc.expectedPath, wsURL.Path)
|
||||||
|
|
||||||
|
// Check headers
|
||||||
|
assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
|
||||||
|
assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
|
||||||
|
assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
|
||||||
|
|
||||||
|
// Test options caching
|
||||||
|
options2 := client.getOptions()
|
||||||
|
assert.Same(t, options, options2, "Options should be cached")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_VerifySignature tests signature verification
|
||||||
|
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Generate test key pairs
|
||||||
|
_, goodPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
keys []ssh.PublicKey
|
||||||
|
token string
|
||||||
|
signWith ed25519.PrivateKey
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid signature with correct key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature with wrong key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: badPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid signature with multiple keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey, goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no valid keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up agent with test keys
|
||||||
|
agent.keys = tc.keys
|
||||||
|
client.token = tc.token
|
||||||
|
|
||||||
|
// Create signature
|
||||||
|
signature := ed25519.Sign(tc.signWith, []byte(tc.token))
|
||||||
|
|
||||||
|
err := client.verifySignature(signature)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid signature")
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic)
|
||||||
|
func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
action common.WebSocketAction
|
||||||
|
hubVerified bool
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CheckFingerprint without verification",
|
||||||
|
action: common.CheckFingerprint,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: false, // CheckFingerprint is allowed without verification
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetData without verification",
|
||||||
|
action: common.GetData,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "hub not verified",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client.hubVerified = tc.hubVerified
|
||||||
|
|
||||||
|
// Create minimal request
|
||||||
|
hubRequest := &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: tc.action,
|
||||||
|
Data: cbor.RawMessage{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.handleHubRequest(hubRequest)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For CheckFingerprint, we expect a decode error since we're not providing valid data,
|
||||||
|
// but it shouldn't be the "hub not verified" error
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.NotContains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetUserAgent tests user agent generation
|
||||||
|
func TestGetUserAgent(t *testing.T) {
|
||||||
|
// Run multiple times to check both variants
|
||||||
|
userAgents := make(map[string]bool)
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
ua := getUserAgent()
|
||||||
|
userAgents[ua] = true
|
||||||
|
|
||||||
|
// Check that it's a valid Mozilla user agent
|
||||||
|
assert.Contains(t, ua, "Mozilla/5.0")
|
||||||
|
assert.Contains(t, ua, "AppleWebKit/537.36")
|
||||||
|
assert.Contains(t, ua, "Chrome/124.0.0.0")
|
||||||
|
assert.Contains(t, ua, "Safari/537.36")
|
||||||
|
|
||||||
|
// Should contain either Windows or Mac
|
||||||
|
isWindows := strings.Contains(ua, "Windows NT 11.0")
|
||||||
|
isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
|
||||||
|
assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With enough iterations, we should see both variants
|
||||||
|
// though this might occasionally fail
|
||||||
|
if len(userAgents) == 1 {
|
||||||
|
t.Log("Note: Only one user agent variant was generated in this test run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_Close tests connection closing
|
||||||
|
func TestWebSocketClient_Close(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test closing with nil connection (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
client.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_ConnectRateLimit tests connection rate limiting
|
||||||
|
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set recent connection attempt
|
||||||
|
client.lastConnectAttempt = time.Now()
|
||||||
|
|
||||||
|
// Test that connection fails quickly due to rate limiting
|
||||||
|
// This won't actually connect but should fail fast
|
||||||
|
err = client.Connect()
|
||||||
|
assert.Error(t, err, "Connection should fail but not hang")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetToken tests the getToken function with various scenarios
|
||||||
|
func TestGetToken(t *testing.T) {
|
||||||
|
unsetEnvVars := func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set TOKEN env var
|
||||||
|
expectedToken := "test-token-from-env"
|
||||||
|
os.Setenv("TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("TOKEN")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||||
|
expectedToken := "test-token-from-beszel-env"
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
expectedToken := "test-token-from-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(expectedToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE env var
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
expectedToken := "test-token-from-beszel-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(expectedToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
fileToken := "token-from-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(fileToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set both TOKEN and TOKEN_FILE
|
||||||
|
envToken := "token-from-env"
|
||||||
|
os.Setenv("TOKEN", envToken)
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}()
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE to a non-existent file
|
||||||
|
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
assert.Contains(t, err.Error(), "no such file or directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create an empty token file
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE env var
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,35 +9,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// getDataDir returns the path to the data directory for the agent and an error
|
// getDataDir returns the path to the data directory for the agent and an error
|
||||||
// if the directory is not valid. Pass an empty string to attempt to find the
|
// if the directory is not valid. Attempts to find the optimal data directory if
|
||||||
// optimal data directory.
|
// no data directories are provided.
|
||||||
func getDataDir(dataDir string) (string, error) {
|
func getDataDir(dataDirs ...string) (string, error) {
|
||||||
if dataDir == "" {
|
if len(dataDirs) > 0 {
|
||||||
dataDir, _ = GetEnv("DATA_DIR")
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataDir, _ := GetEnv("DATA_DIR")
|
||||||
if dataDir != "" {
|
if dataDir != "" {
|
||||||
return testDataDirs([]string{dataDir})
|
dataDirs = append(dataDirs, dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirsToTry []string
|
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
dirsToTry = []string{
|
dataDirs = append(dataDirs,
|
||||||
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
|
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
|
||||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
|
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
|
||||||
}
|
)
|
||||||
} else {
|
} else {
|
||||||
homeDir, err := os.UserHomeDir()
|
dataDirs = append(dataDirs, "/var/lib/beszel-agent")
|
||||||
if err != nil {
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
return "", err
|
dataDirs = append(dataDirs, filepath.Join(homeDir, ".config", "beszel"))
|
||||||
}
|
|
||||||
dirsToTry = []string{
|
|
||||||
"/var/lib/beszel-agent",
|
|
||||||
filepath.Join(homeDir, ".config", "beszel"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return testDataDirs(dirsToTry)
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDataDirs(paths []string) (string, error) {
|
func testDataDirs(paths []string) (string, error) {
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
oldValue := os.Getenv("DATA_DIR")
|
oldValue := os.Getenv("DATA_DIR")
|
||||||
defer func() {
|
defer func() {
|
||||||
if oldValue == "" {
|
if oldValue == "" {
|
||||||
os.Unsetenv("DATA_DIR")
|
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
|
||||||
} else {
|
} else {
|
||||||
os.Setenv("DATA_DIR", oldValue)
|
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
os.Setenv("DATA_DIR", tempDir)
|
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||||
|
|
||||||
result, err := getDataDir("")
|
result, err := getDataDir()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
})
|
})
|
||||||
@@ -79,7 +79,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// This will try platform-specific defaults, which may or may not work
|
// This will try platform-specific defaults, which may or may not work
|
||||||
// We're mainly testing that it doesn't panic and returns some result
|
// We're mainly testing that it doesn't panic and returns some result
|
||||||
result, err := getDataDir("")
|
result, err := getDataDir()
|
||||||
// We don't assert success/failure here since it depends on system permissions
|
// We don't assert success/failure here since it depends on system permissions
|
||||||
// Just verify we get a string result if no error
|
// Just verify we get a string result if no error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"beszel/internal/entities/container"
|
"beszel/internal/entities/container"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -29,7 +29,6 @@ type dockerManager struct {
|
|||||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,17 +342,16 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||||
if dm.buf == nil {
|
if dm.buf == nil {
|
||||||
// initialize buffer with 256kb starting size
|
// initialize buffer with 128kb starting size
|
||||||
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*128))
|
||||||
dm.decoder = json.NewDecoder(dm.buf)
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
defer dm.buf.Reset()
|
dm.buf.Reset()
|
||||||
_, err := dm.buf.ReadFrom(resp.Body)
|
_, err := dm.buf.ReadFrom(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return dm.decoder.Decode(d)
|
return json.Unmarshal(dm.buf.Bytes(), d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test docker / podman sockets and return if one exists
|
// Test docker / podman sockets and return if one exists
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -50,7 +50,7 @@ type GPUManager struct {
|
|||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
type RocmSmiJson struct {
|
type RocmSmiJson struct {
|
||||||
ID string `json:"GUID"`
|
ID string `json:"GUID"`
|
||||||
Name string `json:"Card series"`
|
Name string `json:"Card Series"`
|
||||||
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
||||||
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
||||||
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
||||||
|
|||||||
80
beszel/internal/agent/lhm/beszel_lhm.cs
Normal file
80
beszel/internal/agent/lhm/beszel_lhm.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using LibreHardwareMonitor.Hardware;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
var computer = new Computer
|
||||||
|
{
|
||||||
|
IsCpuEnabled = true,
|
||||||
|
IsGpuEnabled = true,
|
||||||
|
IsMemoryEnabled = true,
|
||||||
|
IsMotherboardEnabled = true,
|
||||||
|
IsStorageEnabled = true,
|
||||||
|
// IsPsuEnabled = true,
|
||||||
|
// IsNetworkEnabled = true,
|
||||||
|
};
|
||||||
|
computer.Open();
|
||||||
|
|
||||||
|
var reader = Console.In;
|
||||||
|
var writer = Console.Out;
|
||||||
|
|
||||||
|
string line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var hw in computer.Hardware)
|
||||||
|
{
|
||||||
|
// process main hardware sensors
|
||||||
|
ProcessSensors(hw, writer);
|
||||||
|
|
||||||
|
// process subhardware sensors
|
||||||
|
foreach (var subhardware in hw.SubHardware)
|
||||||
|
{
|
||||||
|
ProcessSensors(subhardware, writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// send empty line to signal end of sensor data
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
computer.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer)
|
||||||
|
{
|
||||||
|
var updated = false;
|
||||||
|
foreach (var sensor in hardware.Sensors)
|
||||||
|
{
|
||||||
|
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
|
||||||
|
if (!validTemp || sensor.Name.Contains("Distance"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated)
|
||||||
|
{
|
||||||
|
hardware.Update();
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = sensor.Name;
|
||||||
|
// if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name.
|
||||||
|
// usually this is a number like Temperature 3
|
||||||
|
if (sensor.Name.StartsWith("Temperature"))
|
||||||
|
{
|
||||||
|
name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// invariant culture assures the value is parsable as a float
|
||||||
|
var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
// write the name and value to the writer
|
||||||
|
writer.WriteLine($"{name}|{value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
beszel/internal/agent/lhm/beszel_lhm.csproj
Normal file
11
beszel/internal/agent/lhm/beszel_lhm.csproj
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
@@ -82,10 +84,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
// reset high temp
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// retry once on panic (gopsutil/issues/1832)
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error updating temperatures", "err", err)
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
if len(systemStats.Temperatures) > 0 {
|
if len(systemStats.Temperatures) > 0 {
|
||||||
@@ -103,6 +105,11 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
|
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
for i, sensor := range temps {
|
for i, sensor := range temps {
|
||||||
|
// check for malformed strings on darwin (gopsutil/issues/1832)
|
||||||
|
if runtime.GOOS == "darwin" && !utf8.ValidString(sensor.SensorKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// scale temperature
|
// scale temperature
|
||||||
if sensor.Temperature != 0 && sensor.Temperature < 1 {
|
if sensor.Temperature != 0 && sensor.Temperature < 1 {
|
||||||
sensor.Temperature = scaleTemperature(sensor.Temperature)
|
sensor.Temperature = scaleTemperature(sensor.Temperature)
|
||||||
|
|||||||
9
beszel/internal/agent/sensors_default.go
Normal file
9
beszel/internal/agent/sensors_default.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var getSensorTemps = sensors.TemperaturesWithContext
|
||||||
281
beszel/internal/agent/sensors_windows.go
Normal file
281
beszel/internal/agent/sensors_windows.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
//go:generate dotnet build -c Release lhm/beszel_lhm.csproj
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: This is always called from Agent.gatherStats() which holds Agent.Lock(),
|
||||||
|
// so no internal concurrency protection is needed.
|
||||||
|
|
||||||
|
// lhmProcess is a wrapper around the LHM .NET process.
|
||||||
|
type lhmProcess struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
isRunning bool
|
||||||
|
stoppedNoSensors bool
|
||||||
|
consecutiveNoSensors uint8
|
||||||
|
execPath string
|
||||||
|
tempDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed all:lhm/bin/Release/net48
|
||||||
|
var lhmFs embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
beszelLhm *lhmProcess
|
||||||
|
beszelLhmOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoSensors = errors.New("no sensors found (try running as admin)")
|
||||||
|
|
||||||
|
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
||||||
|
func newlhmProcess() (*lhmProcess, error) {
|
||||||
|
destDir := filepath.Join(os.TempDir(), "beszel")
|
||||||
|
execPath := filepath.Join(destDir, "beszel_lhm.exe")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only copy if executable doesn't exist
|
||||||
|
if _, err := os.Stat(execPath); os.IsNotExist(err) {
|
||||||
|
if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to copy embedded directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm := &lhmProcess{
|
||||||
|
execPath: execPath,
|
||||||
|
tempDir: destDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lhm.startProcess(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startProcess starts the external LHM process
|
||||||
|
func (lhm *lhmProcess) startProcess() error {
|
||||||
|
// Clean up any existing process
|
||||||
|
lhm.cleanupProcess()
|
||||||
|
|
||||||
|
cmd := exec.Command(lhm.execPath)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
stdout.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update process state
|
||||||
|
lhm.cmd = cmd
|
||||||
|
lhm.stdin = stdin
|
||||||
|
lhm.stdout = stdout
|
||||||
|
lhm.scanner = bufio.NewScanner(stdout)
|
||||||
|
lhm.isRunning = true
|
||||||
|
|
||||||
|
// Give process a moment to initialize
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupProcess terminates the process and closes resources but preserves files
|
||||||
|
func (lhm *lhmProcess) cleanupProcess() {
|
||||||
|
lhm.isRunning = false
|
||||||
|
|
||||||
|
if lhm.cmd != nil && lhm.cmd.Process != nil {
|
||||||
|
lhm.cmd.Process.Kill()
|
||||||
|
lhm.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhm.stdin != nil {
|
||||||
|
lhm.stdin.Close()
|
||||||
|
lhm.stdin = nil
|
||||||
|
}
|
||||||
|
if lhm.stdout != nil {
|
||||||
|
lhm.stdout.Close()
|
||||||
|
lhm.stdout = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm.cmd = nil
|
||||||
|
lhm.scanner = nil
|
||||||
|
lhm.stoppedNoSensors = false
|
||||||
|
lhm.consecutiveNoSensors = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
if lhm.stoppedNoSensors {
|
||||||
|
// Fall back to gopsutil if we can't get sensors from LHM
|
||||||
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start process if it's not running
|
||||||
|
if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil {
|
||||||
|
err := lhm.startProcess()
|
||||||
|
if err != nil {
|
||||||
|
return temps, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to process
|
||||||
|
_, err = fmt.Fprintln(lhm.stdin, "getTemps")
|
||||||
|
if err != nil {
|
||||||
|
lhm.isRunning = false
|
||||||
|
return temps, fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all sensor lines until we hit an empty line or EOF
|
||||||
|
for lhm.scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(lhm.scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
slog.Debug("Invalid sensor format", "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(parts[0])
|
||||||
|
valueStr := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(valueStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to parse sensor", "err", err, "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" || value <= 0 || value > 150 {
|
||||||
|
slog.Debug("Invalid sensor", "name", name, "val", value, "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
temps = append(temps, sensors.TemperatureStat{
|
||||||
|
SensorKey: name,
|
||||||
|
Temperature: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lhm.scanner.Err(); err != nil {
|
||||||
|
lhm.isRunning = false
|
||||||
|
return temps, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle no sensors case
|
||||||
|
if len(temps) == 0 {
|
||||||
|
lhm.consecutiveNoSensors++
|
||||||
|
if lhm.consecutiveNoSensors >= 3 {
|
||||||
|
lhm.stoppedNoSensors = true
|
||||||
|
slog.Warn(errNoSensors.Error())
|
||||||
|
lhm.cleanup()
|
||||||
|
}
|
||||||
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm.consecutiveNoSensors = 0
|
||||||
|
|
||||||
|
return temps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||||
|
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
||||||
|
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Error reading sensors", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Initialize process once
|
||||||
|
beszelLhmOnce.Do(func() {
|
||||||
|
beszelLhm, err = newlhmProcess()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return temps, fmt.Errorf("failed to initialize lhm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if beszelLhm == nil {
|
||||||
|
return temps, fmt.Errorf("lhm not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return beszelLhm.getTemps(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup terminates the process and closes resources
|
||||||
|
func (lhm *lhmProcess) cleanup() {
|
||||||
|
lhm.cleanupProcess()
|
||||||
|
if lhm.tempDir != "" {
|
||||||
|
os.RemoveAll(lhm.tempDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEmbeddedDir copies the embedded directory to the destination path
|
||||||
|
func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error {
|
||||||
|
entries, err := fs.ReadDir(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
srcEntryPath := path.Join(srcPath, entry.Name())
|
||||||
|
destEntryPath := filepath.Join(destPath, entry.Name())
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fs.ReadFile(srcEntryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(destEntryPath, data, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import (
|
|||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/common"
|
"beszel/internal/common"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json/jsontext"
|
||||||
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -144,7 +145,7 @@ func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersi
|
|||||||
if hubVersion.GTE(beszel.MinVersionCbor) {
|
if hubVersion.GTE(beszel.MinVersionCbor) {
|
||||||
return cbor.NewEncoder(w).Encode(stats)
|
return cbor.NewEncoder(w).Encode(stats)
|
||||||
}
|
}
|
||||||
return json.NewEncoder(w).Encode(stats)
|
return json.MarshalEncode(jsontext.NewEncoder(w), stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractHubVersion extracts the beszel version from SSH client version string.
|
// extractHubVersion extracts the beszel version from SSH client version string.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -473,11 +473,11 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
hubVersion: "0.12.0-beta0",
|
hubVersion: "0.12.0-beta0",
|
||||||
expectedUsesCbor: false,
|
expectedUsesCbor: false,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: "matching beta version should use CBOR",
|
// name: "matching beta version should use CBOR",
|
||||||
hubVersion: "0.12.0-beta2",
|
// hubVersion: "0.12.0-beta2",
|
||||||
expectedUsesCbor: true,
|
// expectedUsesCbor: true,
|
||||||
},
|
// },
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -80,10 +80,11 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// load average
|
// load average
|
||||||
if avgstat, err := load.Avg(); err == nil {
|
if avgstat, err := load.Avg(); err == nil {
|
||||||
systemStats.LoadAvg1 = twoDecimals(avgstat.Load1)
|
// TODO: remove these in future release in favor of load avg array
|
||||||
systemStats.LoadAvg5 = twoDecimals(avgstat.Load5)
|
systemStats.LoadAvg[0] = avgstat.Load1
|
||||||
systemStats.LoadAvg15 = twoDecimals(avgstat.Load15)
|
systemStats.LoadAvg[1] = avgstat.Load5
|
||||||
slog.Debug("Load average", "5m", systemStats.LoadAvg5, "15m", systemStats.LoadAvg15)
|
systemStats.LoadAvg[2] = avgstat.Load15
|
||||||
|
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting load average", "err", err)
|
slog.Error("Error getting load average", "err", err)
|
||||||
}
|
}
|
||||||
@@ -174,24 +175,27 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
}
|
}
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||||
a.netIoStats.Time = time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
bytesSent := uint64(0)
|
totalBytesSent := uint64(0)
|
||||||
bytesRecv := uint64(0)
|
totalBytesRecv := uint64(0)
|
||||||
// sum all bytes sent and received
|
// sum all bytes sent and received
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
// skip if not in valid network interfaces list
|
// skip if not in valid network interfaces list
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bytesSent += v.BytesSent
|
totalBytesSent += v.BytesSent
|
||||||
bytesRecv += v.BytesRecv
|
totalBytesRecv += v.BytesRecv
|
||||||
}
|
}
|
||||||
// add to systemStats
|
// add to systemStats
|
||||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
if msElapsed > 0 {
|
||||||
networkSentPs := bytesToMegabytes(sentPerSecond)
|
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||||
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
// add check for issue (#150) where sent is a massive number
|
// add check for issue (#150) where sent is a massive number
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
@@ -206,9 +210,10 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
} else {
|
} else {
|
||||||
systemStats.NetworkSent = networkSentPs
|
systemStats.NetworkSent = networkSentPs
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
// update netIoStats
|
// update netIoStats
|
||||||
a.netIoStats.BytesSent = bytesSent
|
a.netIoStats.BytesSent = totalBytesSent
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
a.netIoStats.BytesRecv = totalBytesRecv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +256,17 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
|
// TODO: remove these in future release in favor of load avg array
|
||||||
|
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
||||||
|
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
||||||
|
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/nicholas-fedor/shoutrrr"
|
"github.com/nicholas-fedor/shoutrrr"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
)
|
)
|
||||||
@@ -47,8 +46,7 @@ type SystemAlertStats struct {
|
|||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
LoadAvg5 float64 `json:"l5"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
LoadAvg15 float64 `json:"l15"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -92,10 +90,18 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
alertQueue: make(chan alertTask),
|
alertQueue: make(chan alertTask),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
am.bindEvents()
|
||||||
go am.startWorker()
|
go am.startWorker()
|
||||||
return am
|
return am
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind events to the alerts collection lifecycle
|
||||||
|
func (am *AlertManager) bindEvents() {
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
@@ -199,16 +205,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
var data struct {
|
||||||
if info.Auth == nil {
|
URL string `json:"url"`
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
url := e.Request.URL.Query().Get("url")
|
err := e.BindBody(&data)
|
||||||
// log.Println("url", url)
|
if err != nil || data.URL == "" {
|
||||||
if url == "" {
|
return e.BadRequestError("URL is required", err)
|
||||||
return e.JSON(200, map[string]string{"err": "URL is required"})
|
|
||||||
}
|
}
|
||||||
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
119
beszel/internal/alerts/alerts_api.go
Normal file
119
beszel/internal/alerts/alerts_api.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertUserAlerts handles API request to create or update alerts for a user
|
||||||
|
// across multiple systems (POST /api/beszel/user-alerts)
|
||||||
|
func UpsertUserAlerts(e *core.RequestEvent) error {
|
||||||
|
userID := e.Auth.Id
|
||||||
|
|
||||||
|
reqData := struct {
|
||||||
|
Min uint8 `json:"min"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Systems []string `json:"systems"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
}{}
|
||||||
|
err := e.BindBody(&reqData)
|
||||||
|
if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 {
|
||||||
|
return e.BadRequestError("Bad data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for _, systemId := range reqData.Systems {
|
||||||
|
// find existing matching alert
|
||||||
|
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
|
||||||
|
"system={:system} && name={:name} && user={:user}",
|
||||||
|
dbx.Params{"system": systemId, "name": reqData.Name, "user": userID})
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip if alert already exists and overwrite is not set
|
||||||
|
if !reqData.Overwrite && alertRecord != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// create new alert if it doesn't exist
|
||||||
|
if alertRecord == nil {
|
||||||
|
alertRecord = core.NewRecord(alertsCollection)
|
||||||
|
alertRecord.Set("user", userID)
|
||||||
|
alertRecord.Set("system", systemId)
|
||||||
|
alertRecord.Set("name", reqData.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertRecord.Set("value", reqData.Value)
|
||||||
|
alertRecord.Set("min", reqData.Min)
|
||||||
|
|
||||||
|
if err := txApp.SaveNoValidate(alertRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserAlerts handles API request to delete alerts for a user across multiple systems
|
||||||
|
// (DELETE /api/beszel/user-alerts)
|
||||||
|
func DeleteUserAlerts(e *core.RequestEvent) error {
|
||||||
|
userID := e.Auth.Id
|
||||||
|
|
||||||
|
reqData := struct {
|
||||||
|
AlertName string `json:"name"`
|
||||||
|
Systems []string `json:"systems"`
|
||||||
|
}{}
|
||||||
|
err := e.BindBody(&reqData)
|
||||||
|
if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 {
|
||||||
|
return e.BadRequestError("Bad data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var numDeleted uint16
|
||||||
|
|
||||||
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for _, systemId := range reqData.Systems {
|
||||||
|
// Find existing alert to delete
|
||||||
|
alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
|
||||||
|
"system={:system} && name={:name} && user={:user}",
|
||||||
|
dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// alert doesn't exist, continue to next system
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := txApp.Delete(alertRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
numDeleted++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||||
|
}
|
||||||
85
beszel/internal/alerts/alerts_history.go
Normal file
85
beszel/internal/alerts/alerts_history.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// On triggered alert record delete, set matching alert history record to resolved
|
||||||
|
func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
|
||||||
|
if !e.Record.GetBool("triggered") {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
_ = resolveAlertHistoryRecord(e.App, e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// On alert record update, update alert history record
|
||||||
|
func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
||||||
|
original := e.Record.Original()
|
||||||
|
new := e.Record
|
||||||
|
|
||||||
|
originalTriggered := original.GetBool("triggered")
|
||||||
|
newTriggered := new.GetBool("triggered")
|
||||||
|
|
||||||
|
// no need to update alert history if triggered state has not changed
|
||||||
|
if originalTriggered == newTriggered {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if new state is triggered, create new alert history record
|
||||||
|
if newTriggered {
|
||||||
|
_, _ = createAlertHistoryRecord(e.App, new)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if new state is not triggered, check for matching alert history record and set it to resolved
|
||||||
|
_ = resolveAlertHistoryRecord(e.App, new.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||||
|
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
||||||
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
|
"alerts_history",
|
||||||
|
"alert_id={:alert_id} && resolved=null",
|
||||||
|
"-created",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
dbx.Params{"alert_id": alertRecordID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(alertHistoryRecords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
|
||||||
|
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
||||||
|
err = app.Save(alertHistoryRecord)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to resolve alert history", "err", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAlertHistoryRecord creates a new alert history record
|
||||||
|
func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
|
||||||
|
alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
alertHistoryRecord = core.NewRecord(alertHistoryCollection)
|
||||||
|
alertHistoryRecord.Set("alert_id", alertRecord.Id)
|
||||||
|
alertHistoryRecord.Set("user", alertRecord.GetString("user"))
|
||||||
|
alertHistoryRecord.Set("system", alertRecord.GetString("system"))
|
||||||
|
alertHistoryRecord.Set("name", alertRecord.GetString("name"))
|
||||||
|
alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
|
||||||
|
err = app.Save(alertHistoryRecord)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to save alert history", "err", err)
|
||||||
|
}
|
||||||
|
return alertHistoryRecord, err
|
||||||
|
}
|
||||||
@@ -136,6 +136,14 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
|
|||||||
|
|
||||||
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||||
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||||
|
switch alertStatus {
|
||||||
|
case "up":
|
||||||
|
alertRecord.Set("triggered", false)
|
||||||
|
case "down":
|
||||||
|
alertRecord.Set("triggered", true)
|
||||||
|
}
|
||||||
|
am.hub.Save(alertRecord)
|
||||||
|
|
||||||
var emoji string
|
var emoji string
|
||||||
if alertStatus == "up" {
|
if alertStatus == "up" {
|
||||||
emoji = "\u2705" // Green checkmark emoji
|
emoji = "\u2705" // Green checkmark emoji
|
||||||
@@ -146,16 +154,16 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
return errs["user"]
|
// return errs["user"]
|
||||||
}
|
// }
|
||||||
user := alertRecord.ExpandedOne("user")
|
// user := alertRecord.ExpandedOne("user")
|
||||||
if user == nil {
|
// if user == nil {
|
||||||
return nil
|
// return nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: user.Id,
|
UserID: alertRecord.GetString("user"),
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package alerts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
alertRecords, err := am.hub.FindAllRecords("alerts",
|
alertRecords, err := am.hub.FindAllRecords("alerts",
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
// log.Println("no alerts found for system")
|
// log.Println("no alerts found for system")
|
||||||
@@ -54,11 +54,14 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
val = data.Info.DashboardTemp
|
val = data.Info.DashboardTemp
|
||||||
unit = "°C"
|
unit = "°C"
|
||||||
|
case "LoadAvg1":
|
||||||
|
val = data.Info.LoadAvg[0]
|
||||||
|
unit = ""
|
||||||
case "LoadAvg5":
|
case "LoadAvg5":
|
||||||
val = data.Info.LoadAvg5
|
val = data.Info.LoadAvg[1]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg15
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +199,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.mapSums[key] += temp
|
alert.mapSums[key] += temp
|
||||||
}
|
}
|
||||||
|
case "LoadAvg1":
|
||||||
|
alert.val += stats.LoadAvg[0]
|
||||||
case "LoadAvg5":
|
case "LoadAvg5":
|
||||||
alert.val += stats.LoadAvg5
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg15
|
alert.val += stats.LoadAvg[2]
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -288,18 +293,11 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
am.SendAlert(AlertMessageData{
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
Title: subject,
|
||||||
return
|
Message: body,
|
||||||
}
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
LinkText: "View " + systemName,
|
||||||
am.SendAlert(AlertMessageData{
|
})
|
||||||
UserID: user.Id,
|
|
||||||
Title: subject,
|
|
||||||
Message: body,
|
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
368
beszel/internal/alerts/alerts_test.go
Normal file
368
beszel/internal/alerts/alerts_test.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||||
|
user1Token, _ := user1.NewAuthToken()
|
||||||
|
|
||||||
|
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||||
|
user2Token, _ := user2.NewAuthToken()
|
||||||
|
|
||||||
|
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system1",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system2",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
})
|
||||||
|
|
||||||
|
userRecords, _ := hub.CountRecords("users")
|
||||||
|
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||||
|
|
||||||
|
systemRecords, _ := hub.CountRecords("systems")
|
||||||
|
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET not implemented - returns index",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no body",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST bad data",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"invalidField": "this should cause validation error",
|
||||||
|
"threshold": "not a number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST malformed JSON",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data multiple systems",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 69,
|
||||||
|
"min": 9,
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
// check total alerts
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
// check alert has correct values
|
||||||
|
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||||
|
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data single system",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: true, should overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
"overwrite": true,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user2.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE no auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert multiple systems",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"system": systemId,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "should create alert")
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,9 +31,13 @@ type Stats struct {
|
|||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -77,23 +81,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
Cores int `json:"c" cbor:"2,keyasint"`
|
Cores int `json:"c" cbor:"2,keyasint"`
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
||||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
Os Os `json:"os" cbor:"14,keyasint"`
|
Os Os `json:"os" cbor:"14,keyasint"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"15,keyasint,omitempty,omitzero"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"16,keyasint,omitempty,omitzero"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -279,9 +278,8 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
|||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
// Returns the current config.yml file as a JSON object
|
||||||
func GetYamlConfig(e *core.RequestEvent) error {
|
func GetYamlConfig(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
if e.Auth.GetString("role") != "admin" {
|
||||||
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
return e.ForbiddenError("Requires admin role", nil)
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
configContent, err := generateYAML(e.App)
|
configContent, err := generateYAML(e.App)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
|||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||||
// delete old records once every hour
|
// delete old system_stats and alerts_history records once every hour
|
||||||
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||||
@@ -224,48 +224,48 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// returns public key and version
|
// auth protected routes
|
||||||
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
apiAuth := se.Router.Group("/api/beszel")
|
||||||
info, _ := e.RequestInfo()
|
apiAuth.Bind(apis.RequireAuth())
|
||||||
if info.Auth == nil {
|
// auth optional routes
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
apiNoAuth := se.Router.Group("/api/beszel")
|
||||||
}
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
// create first user endpoint only needed if no users exist
|
||||||
})
|
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
|
||||||
|
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
|
||||||
|
}
|
||||||
// check if first time setup on login page
|
// check if first time setup on login page
|
||||||
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
|
||||||
total, err := h.CountRecords("users")
|
total, err := e.App.CountRecords("users")
|
||||||
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||||
})
|
})
|
||||||
|
// get public key and version
|
||||||
|
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||||
|
})
|
||||||
// send test notification
|
// send test notification
|
||||||
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
|
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
// get config.yml content
|
||||||
se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
|
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||||
// handle agent websocket connection
|
// handle agent websocket connection
|
||||||
se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
// get or create universal tokens
|
// get or create universal tokens
|
||||||
se.Router.GET("/api/beszel/universal-token", h.getUniversalToken)
|
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||||
// create first user endpoint only needed if no users exist
|
// update / delete user alerts
|
||||||
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for universal token API endpoint (create, read, delete)
|
// Handler for universal token API endpoint (create, read, delete)
|
||||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||||
info, err := e.RequestInfo()
|
|
||||||
if err != nil || info.Auth == nil {
|
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenMap := universalTokenMap.GetMap()
|
tokenMap := universalTokenMap.GetMap()
|
||||||
userID := info.Auth.Id
|
userID := e.Auth.Id
|
||||||
query := e.Request.URL.Query()
|
query := e.Request.URL.Query()
|
||||||
token := query.Get("token")
|
token := query.Get("token")
|
||||||
tokenSet := token != ""
|
|
||||||
|
|
||||||
if !tokenSet {
|
if token == "" {
|
||||||
// return existing token if it exists
|
// return existing token if it exists
|
||||||
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
||||||
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
||||||
|
|||||||
@@ -4,27 +4,37 @@
|
|||||||
package hub_test
|
package hub_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/tests"
|
beszelTests "beszel/internal/tests"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"encoding/json/v2"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getTestHub(t testing.TB) *tests.TestHub {
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
hub, _ := tests.NewTestHub(t.TempDir())
|
func jsonReader(v any) io.Reader {
|
||||||
return hub
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMakeLink(t *testing.T) {
|
func TestMakeLink(t *testing.T) {
|
||||||
hub := getTestHub(t)
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -114,7 +124,7 @@ func TestMakeLink(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSSHKey(t *testing.T) {
|
func TestGetSSHKey(t *testing.T) {
|
||||||
hub := getTestHub(t)
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
// Test Case 1: Key generation (no existing key)
|
// Test Case 1: Key generation (no existing key)
|
||||||
t.Run("KeyGeneration", func(t *testing.T) {
|
t.Run("KeyGeneration", func(t *testing.T) {
|
||||||
@@ -254,3 +264,340 @@ func TestGetSSHKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApiRoutesAuthentication(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
// Create test user and get auth token
|
||||||
|
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||||
|
require.NoError(t, err, "Failed to create test user")
|
||||||
|
|
||||||
|
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"role": "admin",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to create admin user")
|
||||||
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
|
||||||
|
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
||||||
|
// "email": "superuser@example.com",
|
||||||
|
// "password": "password123",
|
||||||
|
// })
|
||||||
|
// require.NoError(t, err, "Failed to create superuser")
|
||||||
|
|
||||||
|
userToken, err := user.NewAuthToken()
|
||||||
|
require.NoError(t, err, "Failed to create auth token")
|
||||||
|
|
||||||
|
// Create test system for user-alerts endpoints
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to create test system")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
// Auth Protected Routes - Should require authentication
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - no auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - with auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"sending message"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - with user auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Requires admin"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - with admin auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": adminUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test-system"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - with auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"active", "token"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - no auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - with auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - no auth should fail",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - with auth should succeed",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
// Create an alert to delete
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth Optional Routes - Should work without authentication
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auth should also succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /first-run - no auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/first-run",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"firstRun\":false"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /first-run - with auth should also succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/first-run",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"firstRun\":false"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/agent-connect",
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - invalid auth token should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "invalid-token",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - invalid auth token should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "invalid-token",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||||
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Ensure no users exist
|
||||||
|
userCount, err := hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, userCount, "Should start with no users")
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should be available when no users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "firstuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"User created"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
|
||||||
|
// Verify user was created
|
||||||
|
userCount, err = hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, userCount, "Should have created one user")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a user first
|
||||||
|
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should not be available when users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "another@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{"wasn't found"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"beszel/internal/hub/ws"
|
"beszel/internal/hub/ws"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json/jsontext"
|
||||||
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
@@ -275,7 +276,7 @@ func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
|
|||||||
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||||
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||||
} else {
|
} else {
|
||||||
err = json.NewDecoder(stdout).Decode(sys.data)
|
err = json.UnmarshalDecode(jsontext.NewDecoder(stdout), sys.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
|||||||
// - down: Triggers status change alerts
|
// - down: Triggers status change alerts
|
||||||
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
||||||
newStatus := e.Record.GetString("status")
|
newStatus := e.Record.GetString("status")
|
||||||
|
prevStatus := pending
|
||||||
system, ok := sm.systems.GetOk(e.Record.Id)
|
system, ok := sm.systems.GetOk(e.Record.Id)
|
||||||
if ok {
|
if ok {
|
||||||
|
prevStatus = system.Status
|
||||||
system.Status = newStatus
|
system.Status = newStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
if err := sm.AddRecord(e.Record, nil); err != nil {
|
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||||
e.App.Logger().Error("Error adding record", "err", err)
|
e.App.Logger().Error("Error adding record", "err", err)
|
||||||
}
|
}
|
||||||
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +193,6 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
return sm.AddRecord(e.Record, nil)
|
return sm.AddRecord(e.Record, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
prevStatus := system.Status
|
|
||||||
|
|
||||||
// Trigger system alerts when system comes online
|
// Trigger system alerts when system comes online
|
||||||
if newStatus == up {
|
if newStatus == up {
|
||||||
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ func (ws *WsConn) Ping() error {
|
|||||||
|
|
||||||
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
||||||
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
||||||
|
if ws.conn == nil {
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
}
|
||||||
bytes, err := cbor.Marshal(data)
|
bytes, err := cbor.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
221
beszel/internal/hub/ws/ws_test.go
Normal file
221
beszel/internal/hub/ws/ws_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetUpgrader tests the singleton upgrader
|
||||||
|
func TestGetUpgrader(t *testing.T) {
|
||||||
|
// Reset the global upgrader to test singleton behavior
|
||||||
|
upgrader = nil
|
||||||
|
|
||||||
|
// First call should create the upgrader
|
||||||
|
upgrader1 := GetUpgrader()
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should not be nil")
|
||||||
|
|
||||||
|
// Second call should return the same instance
|
||||||
|
upgrader2 := GetUpgrader()
|
||||||
|
assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance")
|
||||||
|
|
||||||
|
// Verify it's properly configured
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should be configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWsConnection tests WebSocket connection creation
|
||||||
|
func TestNewWsConnection(t *testing.T) {
|
||||||
|
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
||||||
|
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
||||||
|
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
|
||||||
|
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_IsConnected tests the connection status check
|
||||||
|
func TestWsConn_IsConnected(t *testing.T) {
|
||||||
|
// Test with nil connection
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_Close tests the connection closing with nil connection
|
||||||
|
func TestWsConn_Close(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Should handle nil connection gracefully
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
wsConn.Close([]byte("test message"))
|
||||||
|
}, "Should not panic when closing nil connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
||||||
|
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
testData := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
Data: "test data",
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will fail because conn is nil, but we can test the CBOR encoding logic
|
||||||
|
// by checking that the function properly encodes to CBOR before failing
|
||||||
|
err := wsConn.sendMessage(testData)
|
||||||
|
assert.Error(t, err, "Should error with nil connection")
|
||||||
|
|
||||||
|
// Test CBOR encoding separately
|
||||||
|
bytes, err := cbor.Marshal(testData)
|
||||||
|
assert.NoError(t, err, "Should encode to CBOR successfully")
|
||||||
|
|
||||||
|
// Verify we can decode it back
|
||||||
|
var decodedData common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(bytes, &decodedData)
|
||||||
|
assert.NoError(t, err, "Should decode from CBOR successfully")
|
||||||
|
assert.Equal(t, testData.Action, decodedData.Action, "Action should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic
|
||||||
|
func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) {
|
||||||
|
// Generate test key pair
|
||||||
|
_, privKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(privKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token := "test-token"
|
||||||
|
|
||||||
|
// This will timeout since conn is nil, but we can verify the signature logic
|
||||||
|
// We can't test the full flow, but we can test that the signature is created properly
|
||||||
|
challenge := []byte(token)
|
||||||
|
signature, err := signer.Sign(nil, challenge)
|
||||||
|
assert.NoError(t, err, "Should create signature successfully")
|
||||||
|
assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty")
|
||||||
|
assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type")
|
||||||
|
|
||||||
|
// Test the fingerprint request structure
|
||||||
|
fpRequest := common.FingerprintRequest{
|
||||||
|
Signature: signature.Blob,
|
||||||
|
NeedSysInfo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding of fingerprint request
|
||||||
|
fpData, err := cbor.Marshal(fpRequest)
|
||||||
|
assert.NoError(t, err, "Should encode fingerprint request to CBOR")
|
||||||
|
|
||||||
|
var decodedFpRequest common.FingerprintRequest
|
||||||
|
err = cbor.Unmarshal(fpData, &decodedFpRequest)
|
||||||
|
assert.NoError(t, err, "Should decode fingerprint request from CBOR")
|
||||||
|
assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match")
|
||||||
|
assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match")
|
||||||
|
|
||||||
|
// Test the full hub request structure
|
||||||
|
hubRequest := common.HubRequest[any]{
|
||||||
|
Action: common.CheckFingerprint,
|
||||||
|
Data: fpRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
hubData, err := cbor.Marshal(hubRequest)
|
||||||
|
assert.NoError(t, err, "Should encode hub request to CBOR")
|
||||||
|
|
||||||
|
var decodedHubRequest common.HubRequest[cbor.RawMessage]
|
||||||
|
err = cbor.Unmarshal(hubData, &decodedHubRequest)
|
||||||
|
assert.NoError(t, err, "Should decode hub request from CBOR")
|
||||||
|
assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_RequestSystemData_RequestFormat tests system data request format
|
||||||
|
func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) {
|
||||||
|
// Test the request format that would be sent
|
||||||
|
request := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding
|
||||||
|
data, err := cbor.Marshal(request)
|
||||||
|
assert.NoError(t, err, "Should encode request to CBOR")
|
||||||
|
|
||||||
|
// Test decoding
|
||||||
|
var decodedRequest common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(data, &decodedRequest)
|
||||||
|
assert.NoError(t, err, "Should decode request from CBOR")
|
||||||
|
assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFingerprintRecord tests the FingerprintRecord struct
|
||||||
|
func TestFingerprintRecord(t *testing.T) {
|
||||||
|
record := FingerprintRecord{
|
||||||
|
Id: "test-id",
|
||||||
|
SystemId: "system-123",
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
Token: "test-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "test-id", record.Id)
|
||||||
|
assert.Equal(t, "system-123", record.SystemId)
|
||||||
|
assert.Equal(t, "test-fingerprint", record.Fingerprint)
|
||||||
|
assert.Equal(t, "test-token", record.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeadlineConstant tests that the deadline constant is reasonable
|
||||||
|
func TestDeadlineConstant(t *testing.T) {
|
||||||
|
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommonActions tests that the common actions are properly defined
|
||||||
|
func TestCommonActions(t *testing.T) {
|
||||||
|
// Test that the actions we use exist and have expected values
|
||||||
|
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||||
|
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler tests that we can create a Handler
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
handler := &Handler{}
|
||||||
|
assert.NotNil(t, handler, "Handler should be created successfully")
|
||||||
|
|
||||||
|
// The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type
|
||||||
|
assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
||||||
|
func TestWsConnChannelBehavior(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Test that channels are properly initialized and can be used
|
||||||
|
select {
|
||||||
|
case wsConn.DownChan <- struct{}{}:
|
||||||
|
// Should be able to write to channel
|
||||||
|
default:
|
||||||
|
t.Error("Should be able to write to DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading from DownChan
|
||||||
|
select {
|
||||||
|
case <-wsConn.DownChan:
|
||||||
|
// Should be able to read from channel
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
t.Error("Should be able to read from DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response channel should be empty initially
|
||||||
|
select {
|
||||||
|
case <-wsConn.responseChan:
|
||||||
|
t.Error("Response channel should be empty initially")
|
||||||
|
default:
|
||||||
|
// Expected - channel should be empty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ package records
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
"beszel/internal/entities/container"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
@@ -203,12 +203,19 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
|
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||||
|
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||||
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
|
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
|
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||||
|
|
||||||
// Accumulate temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
@@ -278,7 +285,11 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
@@ -361,12 +372,46 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes records older than what is displayed in the UI
|
// Delete old records
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
// Define the collections to process
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
err := deleteOldSystemStats(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old alerts history records
|
||||||
|
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
db := app.DB()
|
||||||
|
var users []struct {
|
||||||
|
Id string `db:"user"`
|
||||||
|
}
|
||||||
|
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes system_stats records older than what is displayed in the UI
|
||||||
|
func deleteOldSystemStats(app core.App) error {
|
||||||
|
// Collections to process
|
||||||
collections := [2]string{"system_stats", "container_stats"}
|
collections := [2]string{"system_stats", "container_stats"}
|
||||||
|
|
||||||
// Define record types and their retention periods
|
// Record types and their retention periods
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
recordType string
|
recordType string
|
||||||
retention time.Duration
|
retention time.Duration
|
||||||
@@ -382,10 +427,9 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// Build the WHERE clause dynamically
|
// Build the WHERE clause
|
||||||
var conditionParts []string
|
var conditionParts []string
|
||||||
var params dbx.Params = make(map[string]any)
|
var params dbx.Params = make(map[string]any)
|
||||||
|
|
||||||
for i := range recordData {
|
for i := range recordData {
|
||||||
rd := recordData[i]
|
rd := recordData[i]
|
||||||
// Create parameterized condition for this record type
|
// Create parameterized condition for this record type
|
||||||
@@ -393,19 +437,15 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
||||||
params[dateParam] = now.Add(-rd.retention)
|
params[dateParam] = now.Add(-rd.retention)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine conditions with OR
|
// Combine conditions with OR
|
||||||
conditionStr := strings.Join(conditionParts, " OR ")
|
conditionStr := strings.Join(conditionParts, " OR ")
|
||||||
|
// Construct and execute the full raw query
|
||||||
// Construct the full raw query
|
|
||||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
||||||
|
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
||||||
// Execute the query with parameters
|
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||||
if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
|
||||||
// return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
|
||||||
rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
|
|||||||
381
beszel/internal/records/records_test.go
Normal file
381
beszel/internal/records/records_test.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/tests"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||||
|
func TestDeleteOldRecords(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
|
||||||
|
// Create test user for alerts history
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Create old system_stats records that should be deleted
|
||||||
|
var record *core.Record
|
||||||
|
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// created is autodate field, so we need to set it manually
|
||||||
|
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||||
|
require.Equal(t, record.Get("system"), system.Id)
|
||||||
|
require.Equal(t, record.Get("type"), "1m")
|
||||||
|
|
||||||
|
// Create recent system_stats record that should be kept
|
||||||
|
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||||
|
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create many alerts history records to trigger deletion
|
||||||
|
for i := range 260 { // More than countBeforeDeletion (250)
|
||||||
|
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count records before deletion
|
||||||
|
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
rm.DeleteOldRecords()
|
||||||
|
|
||||||
|
// Count records after deletion
|
||||||
|
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify old system stats were deleted
|
||||||
|
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||||
|
|
||||||
|
// Verify alerts history was trimmed
|
||||||
|
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||||
|
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||||
|
func TestDeleteOldSystemStats(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Test data for different record types and their retention periods
|
||||||
|
testCases := []struct {
|
||||||
|
recordType string
|
||||||
|
retention time.Duration
|
||||||
|
shouldBeKept bool
|
||||||
|
ageFromNow time.Duration
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||||
|
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||||
|
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||||
|
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||||
|
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||||
|
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test records for both system_stats and container_stats
|
||||||
|
collections := []string{"system_stats", "container_stats"}
|
||||||
|
recordIds := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, collection := range collections {
|
||||||
|
recordIds[collection] = make([]string, 0)
|
||||||
|
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordTime := now.Add(-tc.ageFromNow)
|
||||||
|
|
||||||
|
var stats string
|
||||||
|
if collection == "system_stats" {
|
||||||
|
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||||
|
} else {
|
||||||
|
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": tc.recordType,
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.TestDeleteOldSystemStats(hub)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
for _, collection := range collections {
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordId := recordIds[collection][i]
|
||||||
|
|
||||||
|
// Try to find the record
|
||||||
|
_, err := hub.FindRecordById(collection, recordId)
|
||||||
|
|
||||||
|
if tc.shouldBeKept {
|
||||||
|
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||||
|
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
user *core.Record
|
||||||
|
alertCount int
|
||||||
|
countToKeep int
|
||||||
|
countBeforeDeletion int
|
||||||
|
expectedAfterDeletion int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "User with few alerts (below threshold)",
|
||||||
|
user: user1,
|
||||||
|
alertCount: 100,
|
||||||
|
countToKeep: 50,
|
||||||
|
countBeforeDeletion: 150,
|
||||||
|
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||||
|
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User with many alerts (above threshold)",
|
||||||
|
user: user2,
|
||||||
|
alertCount: 300,
|
||||||
|
countToKeep: 100,
|
||||||
|
countBeforeDeletion: 200,
|
||||||
|
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||||
|
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create alerts for this user
|
||||||
|
for i := 0; i < tc.alertCount; i++ {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": tc.user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count before deletion
|
||||||
|
countBefore, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count after deletion
|
||||||
|
countAfter, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||||
|
|
||||||
|
// If deletion occurred, verify the most recent records were kept
|
||||||
|
if tc.expectedAfterDeletion < tc.alertCount {
|
||||||
|
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||||
|
"user = {:user}",
|
||||||
|
"-created", // Order by created DESC
|
||||||
|
tc.countToKeep,
|
||||||
|
0,
|
||||||
|
map[string]any{"user": tc.user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||||
|
|
||||||
|
// Verify records are in descending order by created time
|
||||||
|
for i := 1; i < len(records); i++ {
|
||||||
|
prev := records[i-1].GetDateTime("created").Time()
|
||||||
|
curr := records[i].GetDateTime("created").Time()
|
||||||
|
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||||
|
"Records should be ordered by created time (newest first)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||||
|
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||||
|
// Create user with few alerts
|
||||||
|
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create only 5 alerts (well below threshold)
|
||||||
|
for i := range 5 {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not error and should not delete anything
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
count, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||||
|
// Clear any existing alerts
|
||||||
|
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not error with empty table
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
assert.NotNil(t, rm, "RecordManager should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals tests the twoDecimals helper function
|
||||||
|
func TestTwoDecimals(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{1.234567, 1.23},
|
||||||
|
{1.235, 1.24}, // Should round up
|
||||||
|
{1.0, 1.0},
|
||||||
|
{0.0, 0.0},
|
||||||
|
{-1.234567, -1.23},
|
||||||
|
{-1.235, -1.23}, // Negative rounding
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result := records.TestTwoDecimals(tc.input)
|
||||||
|
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
beszel/internal/records/records_test_helpers.go
Normal file
23
beszel/internal/records/records_test_helpers.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
|
||||||
|
func TestDeleteOldSystemStats(app core.App) error {
|
||||||
|
return deleteOldSystemStats(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
|
||||||
|
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals exposes twoDecimals for testing
|
||||||
|
func TestTwoDecimals(value float64) float64 {
|
||||||
|
return twoDecimals(value)
|
||||||
|
}
|
||||||
307
beszel/internal/tests/api.go
Normal file
307
beszel/internal/tests/api.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: This is a copy of https://github.com/pocketbase/pocketbase/blob/master/tests/api.go
|
||||||
|
// with the following changes:
|
||||||
|
// - Removed automatic cleanup of the test app in ApiScenario.Test (Aug 17 2025)
|
||||||
|
|
||||||
|
// ApiScenario defines a single api request test case/scenario.
|
||||||
|
type ApiScenario struct {
|
||||||
|
// Name is the test name.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Method is the HTTP method of the test request to use.
|
||||||
|
Method string
|
||||||
|
|
||||||
|
// URL is the url/path of the endpoint you want to test.
|
||||||
|
URL string
|
||||||
|
|
||||||
|
// Body specifies the body to send with the request.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// strings.NewReader(`{"title":"abc"}`)
|
||||||
|
Body io.Reader
|
||||||
|
|
||||||
|
// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
|
||||||
|
Headers map[string]string
|
||||||
|
|
||||||
|
// Delay adds a delay before checking the expectations usually
|
||||||
|
// to ensure that all fired non-awaited go routines have finished
|
||||||
|
Delay time.Duration
|
||||||
|
|
||||||
|
// Timeout specifies how long to wait before cancelling the request context.
|
||||||
|
//
|
||||||
|
// A zero or negative value means that there will be no timeout.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// expectations
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// ExpectedStatus specifies the expected response HTTP status code.
|
||||||
|
ExpectedStatus int
|
||||||
|
|
||||||
|
// List of keywords that MUST exist in the response body.
|
||||||
|
//
|
||||||
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||||
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||||
|
ExpectedContent []string
|
||||||
|
|
||||||
|
// List of keywords that MUST NOT exist in the response body.
|
||||||
|
//
|
||||||
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||||
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||||
|
NotExpectedContent []string
|
||||||
|
|
||||||
|
// List of hook events to check whether they were fired or not.
|
||||||
|
//
|
||||||
|
// You can use the wildcard "*" event key if you want to ensure
|
||||||
|
// that no other hook events except those listed have been fired.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// map[string]int{ "*": 0 } // no hook events were fired
|
||||||
|
// map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
|
||||||
|
// map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
|
||||||
|
ExpectedEvents map[string]int
|
||||||
|
|
||||||
|
// test hooks
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
TestAppFactory func(t testing.TB) *pbtests.TestApp
|
||||||
|
BeforeTestFunc func(t testing.TB, app *pbtests.TestApp, e *core.ServeEvent)
|
||||||
|
AfterTestFunc func(t testing.TB, app *pbtests.TestApp, res *http.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test executes the test scenario.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func TestListExample(t *testing.T) {
|
||||||
|
// scenario := tests.ApiScenario{
|
||||||
|
// Name: "list example collection",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/collections/example/records",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{
|
||||||
|
// `"totalItems":3`,
|
||||||
|
// `"id":"0yxhwia2amd8gec"`,
|
||||||
|
// `"id":"achvryl401bhse3"`,
|
||||||
|
// `"id":"llvuca81nly1qls"`,
|
||||||
|
// },
|
||||||
|
// ExpectedEvents: map[string]int{
|
||||||
|
// "OnRecordsListRequest": 1,
|
||||||
|
// "OnRecordEnrich": 3,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// scenario.Test(t)
|
||||||
|
// }
|
||||||
|
func (scenario *ApiScenario) Test(t *testing.T) {
|
||||||
|
t.Run(scenario.normalizedName(), func(t *testing.T) {
|
||||||
|
scenario.test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark benchmarks the test scenario.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func BenchmarkListExample(b *testing.B) {
|
||||||
|
// scenario := tests.ApiScenario{
|
||||||
|
// Name: "list example collection",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/collections/example/records",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{
|
||||||
|
// `"totalItems":3`,
|
||||||
|
// `"id":"0yxhwia2amd8gec"`,
|
||||||
|
// `"id":"achvryl401bhse3"`,
|
||||||
|
// `"id":"llvuca81nly1qls"`,
|
||||||
|
// },
|
||||||
|
// ExpectedEvents: map[string]int{
|
||||||
|
// "OnRecordsListRequest": 1,
|
||||||
|
// "OnRecordEnrich": 3,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// scenario.Benchmark(b)
|
||||||
|
// }
|
||||||
|
func (scenario *ApiScenario) Benchmark(b *testing.B) {
|
||||||
|
b.Run(scenario.normalizedName(), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
scenario.test(b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenario *ApiScenario) normalizedName() string {
|
||||||
|
var name = scenario.Name
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenario *ApiScenario) test(t testing.TB) {
|
||||||
|
var testApp *pbtests.TestApp
|
||||||
|
if scenario.TestAppFactory != nil {
|
||||||
|
testApp = scenario.TestAppFactory(t)
|
||||||
|
if testApp == nil {
|
||||||
|
t.Fatal("TestAppFactory must return a non-nill app instance")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var testAppErr error
|
||||||
|
testApp, testAppErr = pbtests.NewTestApp()
|
||||||
|
if testAppErr != nil {
|
||||||
|
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// defer testApp.Cleanup()
|
||||||
|
|
||||||
|
baseRouter, err := apis.NewRouter(testApp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
|
||||||
|
serveEvent := new(core.ServeEvent)
|
||||||
|
serveEvent.App = testApp
|
||||||
|
serveEvent.Router = baseRouter
|
||||||
|
|
||||||
|
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||||
|
if scenario.BeforeTestFunc != nil {
|
||||||
|
scenario.BeforeTestFunc(t, testApp, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset the event counters in case a hook was triggered from a before func (eg. db save)
|
||||||
|
testApp.ResetEventCalls()
|
||||||
|
|
||||||
|
// add middleware to timeout long-running requests (eg. keep-alive routes)
|
||||||
|
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
|
||||||
|
Func: func(re *core.RequestEvent) error {
|
||||||
|
slowTimer := time.AfterFunc(3*time.Second, func() {
|
||||||
|
t.Logf("[WARN] Long running test %q", scenario.Name)
|
||||||
|
})
|
||||||
|
defer slowTimer.Stop()
|
||||||
|
|
||||||
|
if scenario.Timeout > 0 {
|
||||||
|
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
|
||||||
|
defer cancelFunc()
|
||||||
|
re.Request = re.Request.Clone(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return re.Next()
|
||||||
|
},
|
||||||
|
Priority: -9999,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
|
||||||
|
|
||||||
|
// set default header
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
|
||||||
|
// set scenario headers
|
||||||
|
for k, v := range scenario.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute request
|
||||||
|
mux, err := e.Router.BuildMux()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to build router mux: %v", err)
|
||||||
|
}
|
||||||
|
mux.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
res := recorder.Result()
|
||||||
|
|
||||||
|
if res.StatusCode != scenario.ExpectedStatus {
|
||||||
|
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario.Delay > 0 {
|
||||||
|
time.Sleep(scenario.Delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
||||||
|
if len(recorder.Body.Bytes()) != 0 {
|
||||||
|
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// normalize json response format
|
||||||
|
/* buffer := new(bytes.Buffer)
|
||||||
|
err := json.Compact(buffer, recorder.Body.Bytes())
|
||||||
|
var normalizedBody string
|
||||||
|
if err != nil {
|
||||||
|
// not a json...
|
||||||
|
normalizedBody = recorder.Body.String()
|
||||||
|
} else {
|
||||||
|
normalizedBody = buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range scenario.ExpectedContent {
|
||||||
|
if !strings.Contains(normalizedBody, item) {
|
||||||
|
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range scenario.NotExpectedContent {
|
||||||
|
if strings.Contains(normalizedBody, item) {
|
||||||
|
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingEvents := maps.Clone(testApp.EventCalls)
|
||||||
|
|
||||||
|
var noOtherEventsShouldRemain bool
|
||||||
|
for event, expectedNum := range scenario.ExpectedEvents {
|
||||||
|
if event == "*" && expectedNum <= 0 {
|
||||||
|
noOtherEventsShouldRemain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
actualNum := remainingEvents[event]
|
||||||
|
if actualNum != expectedNum {
|
||||||
|
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(remainingEvents, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
|
||||||
|
t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario.AfterTestFunc != nil {
|
||||||
|
scenario.AfterTestFunc(t, testApp, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if serveErr != nil {
|
||||||
|
t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ package tests
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
_ "github.com/pocketbase/pocketbase/migrations"
|
_ "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
@@ -86,3 +89,10 @@ func CreateRecord(app core.App, collectionName string, fields map[string]any) (*
|
|||||||
|
|
||||||
return record, app.Save(record)
|
return record, app.Save(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ClearCollection(t testing.TB, app core.App, collectionName string) error {
|
||||||
|
_, err := app.DB().NewQuery(fmt.Sprintf("DELETE from %s", collectionName)).Execute()
|
||||||
|
recordCount, err := app.CountRecords(collectionName)
|
||||||
|
assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,13 +14,6 @@ type UserManager struct {
|
|||||||
app core.App
|
app core.App
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSettings struct {
|
|
||||||
ChartTime string `json:"chartTime"`
|
|
||||||
NotificationEmails []string `json:"emails"`
|
|
||||||
NotificationWebhooks []string `json:"webhooks"`
|
|
||||||
// Language string `json:"lang"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserManager(app core.App) *UserManager {
|
func NewUserManager(app core.App) *UserManager {
|
||||||
return &UserManager{
|
return &UserManager{
|
||||||
app: app,
|
app: app,
|
||||||
@@ -37,30 +31,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
|||||||
// Initialize user settings with defaults if not set
|
// Initialize user settings with defaults if not set
|
||||||
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
record := e.Record
|
record := e.Record
|
||||||
// intialize settings with defaults
|
// intialize settings with defaults (zero values can be ignored)
|
||||||
settings := UserSettings{
|
settings := struct {
|
||||||
// Language: "en",
|
ChartTime string `json:"chartTime"`
|
||||||
ChartTime: "1h",
|
Emails []string `json:"emails"`
|
||||||
NotificationEmails: []string{},
|
}{
|
||||||
NotificationWebhooks: []string{},
|
ChartTime: "1h",
|
||||||
}
|
}
|
||||||
record.UnmarshalJSONField("settings", &settings)
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
if len(settings.NotificationEmails) == 0 {
|
// get user email from auth record
|
||||||
// get user email from auth record
|
var user struct {
|
||||||
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
Email string `db:"email"`
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
|
||||||
if user := record.ExpandedOne("user"); user != nil {
|
|
||||||
settings.NotificationEmails = []string{user.GetString("email")}
|
|
||||||
} else {
|
|
||||||
log.Println("Failed to get user email from auth record")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Println("failed to expand user relation", "errs", errs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// if len(settings.NotificationWebhooks) == 0 {
|
err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
|
||||||
// settings.NotificationWebhooks = []string{""}
|
"id": record.GetString("user"),
|
||||||
// }
|
}).One(&user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("failed to get user email", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings.Emails = []string{user.Email}
|
||||||
record.Set("settings", settings)
|
record.Set("settings", settings)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15"
|
||||||
]
|
]
|
||||||
@@ -139,6 +140,124 @@ func init() {
|
|||||||
],
|
],
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1697146157",
|
||||||
|
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"name": "alerts_history",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2375276105",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "user",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3377271179",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2466471794",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "alert_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number494360628",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "value",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date2276568630",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "resolved",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_YdGnup5aqB` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `user` + "`" + `)",
|
||||||
|
"CREATE INDEX ` + "`" + `idx_taLet9VdME` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `created` + "`" + `)"
|
||||||
|
],
|
||||||
|
"system": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "juohu4jipgc13v7",
|
"id": "juohu4jipgc13v7",
|
||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\"",
|
||||||
@@ -756,7 +875,6 @@ func init() {
|
|||||||
LEFT JOIN fingerprints f ON s.id = f.system
|
LEFT JOIN fingerprints f ON s.id = f.system
|
||||||
WHERE f.system IS NULL
|
WHERE f.system IS NULL
|
||||||
`).Column(&systemIds)
|
`).Column(&systemIds)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = {
|
||||||
|
|||||||
435
beszel/site/package-lock.json
generated
435
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0-beta2",
|
"version": "0.12.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
"@lingui/detect-locale": "^5.3.2",
|
"@lingui/detect-locale": "^5.3.3",
|
||||||
"@lingui/macro": "^5.3.2",
|
"@lingui/macro": "^5.3.3",
|
||||||
"@lingui/react": "^5.3.2",
|
"@lingui/react": "^5.3.3",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.11.0",
|
"@nanostores/router": "^0.11.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
@@ -39,24 +39,25 @@
|
|||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.0",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.3",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@lingui/vite-plugin": "^5.3.2",
|
"@lingui/vite-plugin": "^5.3.3",
|
||||||
"@types/bun": "^1.2.15",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"@types/bun": "^1.2.19",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.1",
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.4",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-rtl": "^0.9.0",
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
|
|||||||
164
beszel/site/src/components/alerts-history-columns.tsx
Normal file
164
beszel/site/src/components/alerts-history-columns.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { AlertsHistoryRecord } from "@/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { alertInfo, formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
|
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "system",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
|
||||||
|
filterFn: (row, _, filterValue) => {
|
||||||
|
const display = row.original.expand?.system?.name || row.original.system || ""
|
||||||
|
return display.toLowerCase().includes(filterValue.toLowerCase())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// accessorKey: "name",
|
||||||
|
id: "name",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
const name = record.name
|
||||||
|
const info = alertInfo[name]
|
||||||
|
return info?.name().replace("cpu", "CPU") || name
|
||||||
|
},
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
let name = getValue() as string
|
||||||
|
const info = alertInfo[row.original.name]
|
||||||
|
const Icon = info?.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2 ps-1 min-w-40">
|
||||||
|
{Icon && <Icon className="size-3.5" />}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "value",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => (
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Trans>Value</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell({ row, getValue }) {
|
||||||
|
const name = row.original.name
|
||||||
|
if (name === "Status") {
|
||||||
|
return <span className="ps-2">{t`Down`}</span>
|
||||||
|
}
|
||||||
|
const value = getValue() as number
|
||||||
|
const unit = alertInfo[name]?.unit
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums ps-2.5">
|
||||||
|
{toFixedFloat(value, value < 10 ? 2 : 1)}
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "state",
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans comment="Context: alert state (active or resolved)">State</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resolved = row.original.resolved
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"capitalize pointer-events-none",
|
||||||
|
resolved
|
||||||
|
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
|
||||||
|
: "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
|
||||||
|
{resolved ? <Trans>Resolved</Trans> : <Trans>Active</Trans>}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created",
|
||||||
|
accessorFn: (record) => formatShortDate(record.created),
|
||||||
|
enableSorting: true,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans comment="Context: date created">Created</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
|
||||||
|
{getValue() as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "resolved",
|
||||||
|
enableSorting: true,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Resolved</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row, getValue }) => {
|
||||||
|
const resolved = getValue() as string | null
|
||||||
|
if (!resolved) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
|
||||||
|
{formatShortDate(resolved)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "duration",
|
||||||
|
invertSorting: true,
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aCreated = new Date(rowA.original.created)
|
||||||
|
const bCreated = new Date(rowB.original.created)
|
||||||
|
const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
|
||||||
|
const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
|
||||||
|
const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
|
||||||
|
const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
|
||||||
|
if (!aDuration && bDuration) return -1
|
||||||
|
if (aDuration && !bDuration) return 1
|
||||||
|
return (aDuration || 0) - (bDuration || 0)
|
||||||
|
},
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Duration</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const duration = formatDuration(row.original.created, row.original.resolved)
|
||||||
|
if (!duration) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <span className="ps-2">{duration}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,31 +1,19 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { memo, useMemo, useState } from "react"
|
import { memo, useMemo, useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import {
|
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog"
|
||||||
Dialog,
|
import { BellIcon } from "lucide-react"
|
||||||
DialogTrigger,
|
import { cn } from "@/lib/utils"
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import { $router, Link } from "../router"
|
import { AlertDialogContent } from "./alerts-dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Checkbox } from "../ui/checkbox"
|
|
||||||
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
|
|
||||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
|
||||||
const hasAlert = alerts.some((alert) => alert.system === system.id)
|
const hasSystemAlert = alerts[system.id]?.size > 0
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -34,7 +22,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||||
"fill-primary": hasAlert,
|
"fill-primary": hasSystemAlert,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -44,7 +32,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
),
|
),
|
||||||
[opened, hasAlert]
|
[opened, hasSystemAlert]
|
||||||
)
|
)
|
||||||
|
|
||||||
// return useMemo(
|
// return useMemo(
|
||||||
@@ -67,87 +55,3 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
// [opened, hasAlert]
|
// [opened, hasAlert]
|
||||||
// )
|
// )
|
||||||
})
|
})
|
||||||
|
|
||||||
function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
|
||||||
|
|
||||||
// alertsSignature changes only when alerts for this system change
|
|
||||||
let alertsSignature = ""
|
|
||||||
const systemAlerts = alerts.filter((alert) => {
|
|
||||||
if (alert.system === system.id) {
|
|
||||||
alertsSignature += alert.name + alert.min + alert.value
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}) as AlertRecord[]
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
// console.log("render modal", system.name, alertsSignature)
|
|
||||||
const data = Object.keys(alertInfo).map((name) => {
|
|
||||||
const alert = alertInfo[name as keyof typeof alertInfo]
|
|
||||||
return {
|
|
||||||
name: name as keyof typeof alertInfo,
|
|
||||||
alert,
|
|
||||||
system,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl">
|
|
||||||
<Trans>Alerts</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
See{" "}
|
|
||||||
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
|
||||||
notification settings
|
|
||||||
</Link>{" "}
|
|
||||||
to configure how you receive alerts.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Tabs defaultValue="system">
|
|
||||||
<TabsList className="mb-1 -mt-0.5">
|
|
||||||
<TabsTrigger value="system">
|
|
||||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
|
||||||
{system.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="global">
|
|
||||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
|
||||||
<Trans>All Systems</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="system">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="global">
|
|
||||||
<label
|
|
||||||
htmlFor="ovw"
|
|
||||||
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="ovw"
|
|
||||||
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
|
||||||
checked={overwriteExisting}
|
|
||||||
onCheckedChange={setOverwriteExisting}
|
|
||||||
/>
|
|
||||||
<Trans>Overwrite existing alerts</Trans>
|
|
||||||
</label>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [alertsSignature, overwriteExisting])
|
|
||||||
}
|
|
||||||
|
|||||||
297
beszel/site/src/components/alerts/alerts-dialog.tsx
Normal file
297
beszel/site/src/components/alerts/alerts-dialog.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro"
|
||||||
|
import { $alerts, $systems, pb } from "@/lib/stores"
|
||||||
|
import { alertInfo, cn, debounce } from "@/lib/utils"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||||
|
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
|
import { ServerIcon, GlobeIcon } from "lucide-react"
|
||||||
|
import { $router, Link } from "@/components/router"
|
||||||
|
import { DialogHeader } from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
|
const endpoint = "/api/beszel/user-alerts"
|
||||||
|
|
||||||
|
const alertDebounce = 100
|
||||||
|
|
||||||
|
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
|
||||||
|
|
||||||
|
const failedUpdateToast = (error: unknown) => {
|
||||||
|
console.error(error)
|
||||||
|
toast({
|
||||||
|
title: t`Failed to update alert`,
|
||||||
|
description: t`Please check logs for more details.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or update alerts for a given name and systems */
|
||||||
|
const upsertAlerts = debounce(
|
||||||
|
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
|
||||||
|
try {
|
||||||
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
// overwrite is always true because we've done filtering client side
|
||||||
|
body: { name, value, min, systems, overwrite: true },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alertDebounce
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Delete alerts for a given name and systems */
|
||||||
|
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
|
||||||
|
try {
|
||||||
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { name, systems },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
|
}
|
||||||
|
}, alertDebounce)
|
||||||
|
|
||||||
|
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
|
const [currentTab, setCurrentTab] = useState("system")
|
||||||
|
|
||||||
|
const systemAlerts = alerts[system.id] ?? new Map()
|
||||||
|
|
||||||
|
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
|
||||||
|
// current alerts, it will only be updated when first checked, then won't be updated because
|
||||||
|
// after that it exists.
|
||||||
|
const alertsWhenGlobalSelected = useMemo(() => {
|
||||||
|
return currentTab === "global" ? structuredClone(alerts) : alerts
|
||||||
|
}, [currentTab])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">
|
||||||
|
<Trans>Alerts</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
See{" "}
|
||||||
|
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
||||||
|
notification settings
|
||||||
|
</Link>{" "}
|
||||||
|
to configure how you receive alerts.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
||||||
|
<TabsList className="mb-1 -mt-0.5">
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||||
|
{system.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||||
|
<Trans>All Systems</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="system">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alertKeys.map((name) => (
|
||||||
|
<AlertContent
|
||||||
|
key={name}
|
||||||
|
alertKey={name}
|
||||||
|
data={alertInfo[name as keyof typeof alertInfo]}
|
||||||
|
alert={systemAlerts.get(name)}
|
||||||
|
system={system}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="global">
|
||||||
|
<label
|
||||||
|
htmlFor="ovw"
|
||||||
|
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="ovw"
|
||||||
|
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
||||||
|
checked={overwriteExisting}
|
||||||
|
onCheckedChange={setOverwriteExisting}
|
||||||
|
/>
|
||||||
|
<Trans>Overwrite existing alerts</Trans>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alertKeys.map((name) => (
|
||||||
|
<AlertContent
|
||||||
|
key={name}
|
||||||
|
alertKey={name}
|
||||||
|
system={system}
|
||||||
|
alert={systemAlerts.get(name)}
|
||||||
|
data={alertInfo[name as keyof typeof alertInfo]}
|
||||||
|
global={true}
|
||||||
|
overwriteExisting={!!overwriteExisting}
|
||||||
|
initialAlertsState={alertsWhenGlobalSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function AlertContent({
|
||||||
|
alertKey,
|
||||||
|
data: alertData,
|
||||||
|
system,
|
||||||
|
alert,
|
||||||
|
global = false,
|
||||||
|
overwriteExisting = false,
|
||||||
|
initialAlertsState = {},
|
||||||
|
}: {
|
||||||
|
alertKey: string
|
||||||
|
data: AlertInfo
|
||||||
|
system: SystemRecord
|
||||||
|
alert?: AlertRecord
|
||||||
|
global?: boolean
|
||||||
|
overwriteExisting?: boolean
|
||||||
|
initialAlertsState?: Record<string, Map<string, AlertRecord>>
|
||||||
|
}) {
|
||||||
|
const { name } = alertData
|
||||||
|
|
||||||
|
const singleDescription = alertData.singleDesc?.()
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState(global ? false : !!alert)
|
||||||
|
const [min, setMin] = useState(alert?.min || 10)
|
||||||
|
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
|
||||||
|
|
||||||
|
const Icon = alertData.icon
|
||||||
|
|
||||||
|
/** Get system ids to update */
|
||||||
|
function getSystemIds(): string[] {
|
||||||
|
// if not global, update only the current system
|
||||||
|
if (!global) {
|
||||||
|
return [system.id]
|
||||||
|
}
|
||||||
|
// if global, update all systems when overwriteExisting is true
|
||||||
|
// update only systems without an existing alert when overwriteExisting is false
|
||||||
|
const allSystems = $systems.get()
|
||||||
|
const systemIds: string[] = []
|
||||||
|
for (const system of allSystems) {
|
||||||
|
if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
|
||||||
|
systemIds.push(system.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemIds
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendUpsert(min: number, value: number) {
|
||||||
|
const systems = getSystemIds()
|
||||||
|
systems.length &&
|
||||||
|
upsertAlerts({
|
||||||
|
name: alertKey,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
systems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
||||||
|
<label
|
||||||
|
htmlFor={`s${name}`}
|
||||||
|
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||||
|
"pb-0": checked,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1 select-none">
|
||||||
|
<p className="font-semibold flex gap-3 items-center">
|
||||||
|
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
|
||||||
|
</p>
|
||||||
|
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`s${name}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(newChecked) => {
|
||||||
|
setChecked(newChecked)
|
||||||
|
if (newChecked) {
|
||||||
|
// if alert checked, create or update alert
|
||||||
|
sendUpsert(min, value)
|
||||||
|
} else {
|
||||||
|
// if unchecked, delete alert (unless global and overwriteExisting is false)
|
||||||
|
deleteAlerts({ name: alertKey, systems: getSystemIds() })
|
||||||
|
// when force deleting all alerts of a type, also remove them from initialAlertsState
|
||||||
|
if (overwriteExisting) {
|
||||||
|
for (const curAlerts of Object.values(initialAlertsState)) {
|
||||||
|
curAlerts.delete(alertKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{checked && (
|
||||||
|
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
||||||
|
<Suspense fallback={<div className="h-10" />}>
|
||||||
|
{!singleDescription && (
|
||||||
|
<div>
|
||||||
|
<p id={`v${name}`} className="text-sm block h-8">
|
||||||
|
<Trans>
|
||||||
|
Average exceeds{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{value}
|
||||||
|
{alertData.unit}
|
||||||
|
</strong>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${name}`}
|
||||||
|
defaultValue={[value]}
|
||||||
|
onValueCommit={(val) => sendUpsert(min, val[0])}
|
||||||
|
onValueChange={(val) => setValue(val[0])}
|
||||||
|
step={alertData.step ?? 1}
|
||||||
|
min={alertData.min ?? 1}
|
||||||
|
max={alertData.max ?? 99}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||||
|
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
|
||||||
|
{singleDescription && (
|
||||||
|
<>
|
||||||
|
{singleDescription}
|
||||||
|
{` `}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Trans>
|
||||||
|
For <strong className="text-foreground">{min}</strong>{" "}
|
||||||
|
<Plural value={min} one="minute" other="minutes" />
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${name}`}
|
||||||
|
defaultValue={[min]}
|
||||||
|
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
|
||||||
|
onValueChange={(val) => setMin(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans, Plural } from "@lingui/react/macro"
|
|
||||||
import { $alerts, $systems, pb } from "@/lib/stores"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
|
||||||
import { lazy, Suspense, useMemo, useState } from "react"
|
|
||||||
import { toast } from "../ui/use-toast"
|
|
||||||
import { BatchService } from "pocketbase"
|
|
||||||
import { getSemaphore } from "@henrygd/semaphore"
|
|
||||||
|
|
||||||
interface AlertData {
|
|
||||||
checked?: boolean
|
|
||||||
val?: number
|
|
||||||
min?: number
|
|
||||||
updateAlert?: (checked: boolean, value: number, min: number) => void
|
|
||||||
name: keyof typeof alertInfo
|
|
||||||
alert: AlertInfo
|
|
||||||
system: SystemRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
|
||||||
toast({
|
|
||||||
title: t`Failed to update alert`,
|
|
||||||
description: t`Please check logs for more details.`,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
|
|
||||||
export function SystemAlert({
|
|
||||||
system,
|
|
||||||
systemAlerts,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
system: SystemRecord
|
|
||||||
systemAlerts: AlertRecord[]
|
|
||||||
data: AlertData
|
|
||||||
}) {
|
|
||||||
const alert = systemAlerts.find((alert) => alert.name === data.name)
|
|
||||||
|
|
||||||
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
|
||||||
try {
|
|
||||||
if (alert && !checked) {
|
|
||||||
await pb.collection("alerts").delete(alert.id)
|
|
||||||
} else if (alert && checked) {
|
|
||||||
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
|
|
||||||
} else if (checked) {
|
|
||||||
pb.collection("alerts").create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
value: value,
|
|
||||||
min: min,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
failedUpdateToast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alert) {
|
|
||||||
data.checked = true
|
|
||||||
data.val = alert.value
|
|
||||||
data.min = alert.min || 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertContent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
|
|
||||||
data.checked = false
|
|
||||||
data.val = data.min = 0
|
|
||||||
|
|
||||||
// set of system ids that have an alert for this name when the component is mounted
|
|
||||||
const existingAlertsSystems = useMemo(() => {
|
|
||||||
const map = new Set<string>()
|
|
||||||
const alerts = $alerts.get()
|
|
||||||
for (const alert of alerts) {
|
|
||||||
if (alert.name === data.name) {
|
|
||||||
map.add(alert.system)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
|
||||||
const sem = getSemaphore("alerts")
|
|
||||||
await sem.acquire()
|
|
||||||
try {
|
|
||||||
// if another update is waiting behind, don't start this one
|
|
||||||
if (sem.size() > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordData: Partial<AlertRecord> = {
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
triggered: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const batch = batchWrapper("alerts", 25)
|
|
||||||
const systems = $systems.get()
|
|
||||||
const currentAlerts = $alerts.get()
|
|
||||||
|
|
||||||
// map of current alerts with this name right now by system id
|
|
||||||
const currentAlertsSystems = new Map<string, AlertRecord>()
|
|
||||||
for (const alert of currentAlerts) {
|
|
||||||
if (alert.name === data.name) {
|
|
||||||
currentAlertsSystems.set(alert.system, alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overwrite) {
|
|
||||||
existingAlertsSystems.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
const processSystem = async (system: SystemRecord): Promise<void> => {
|
|
||||||
const existingAlert = existingAlertsSystems.has(system.id)
|
|
||||||
|
|
||||||
if (!overwrite && existingAlert) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentAlert = currentAlertsSystems.get(system.id)
|
|
||||||
|
|
||||||
// delete existing alert if unchecked
|
|
||||||
if (!checked && currentAlert) {
|
|
||||||
return batch.remove(currentAlert.id)
|
|
||||||
}
|
|
||||||
if (checked && currentAlert) {
|
|
||||||
// update existing alert if checked
|
|
||||||
return batch.update(currentAlert.id, recordData)
|
|
||||||
}
|
|
||||||
if (checked) {
|
|
||||||
// create new alert if checked and not existing
|
|
||||||
return batch.create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
...recordData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure current system is updated in the first batch
|
|
||||||
await processSystem(data.system)
|
|
||||||
for (const system of systems) {
|
|
||||||
if (system.id === data.system.id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (sem.size() > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await processSystem(system)
|
|
||||||
}
|
|
||||||
await batch.send()
|
|
||||||
} finally {
|
|
||||||
sem.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertContent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a wrapper for performing batch operations on a specified collection.
|
|
||||||
*/
|
|
||||||
function batchWrapper(collection: string, batchSize: number) {
|
|
||||||
let batch: BatchService | undefined
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
const create = async <T extends Record<string, any>>(options: T) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).create(options)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = async <T extends Record<string, any>>(id: string, data: T) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).update(id, data)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).delete(id)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
if (count) {
|
|
||||||
await batch?.send({ requestKey: null })
|
|
||||||
batch = undefined
|
|
||||||
count = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
update,
|
|
||||||
remove,
|
|
||||||
send,
|
|
||||||
create,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertContent({ data }: { data: AlertData }) {
|
|
||||||
const { name } = data
|
|
||||||
|
|
||||||
const singleDescription = data.alert.singleDesc?.()
|
|
||||||
|
|
||||||
const [checked, setChecked] = useState(data.checked || false)
|
|
||||||
const [min, setMin] = useState(data.min || 10)
|
|
||||||
const [value, setValue] = useState(data.val || (singleDescription ? 0 : data.alert.start ?? 80))
|
|
||||||
|
|
||||||
const Icon = alertInfo[name].icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
|
||||||
<label
|
|
||||||
htmlFor={`s${name}`}
|
|
||||||
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
|
||||||
"pb-0": checked,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="grid gap-1 select-none">
|
|
||||||
<p className="font-semibold flex gap-3 items-center">
|
|
||||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
|
||||||
</p>
|
|
||||||
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={`s${name}`}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(newChecked) => {
|
|
||||||
setChecked(newChecked)
|
|
||||||
data.updateAlert?.(newChecked, value, min)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{checked && (
|
|
||||||
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
|
||||||
<Suspense fallback={<div className="h-10" />}>
|
|
||||||
{!singleDescription && (
|
|
||||||
<div>
|
|
||||||
<p id={`v${name}`} className="text-sm block h-8">
|
|
||||||
<Trans>
|
|
||||||
Average exceeds{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{value}
|
|
||||||
{data.alert.unit}
|
|
||||||
</strong>
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Slider
|
|
||||||
aria-labelledby={`v${name}`}
|
|
||||||
defaultValue={[value]}
|
|
||||||
onValueCommit={(val) => {
|
|
||||||
data.updateAlert?.(true, val[0], min)
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setValue(val[0])
|
|
||||||
}}
|
|
||||||
step={data.alert.step ?? 1}
|
|
||||||
min={data.alert.min ?? 1}
|
|
||||||
max={alertInfo[name].max ?? 99}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
|
||||||
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
|
|
||||||
{singleDescription && (
|
|
||||||
<>
|
|
||||||
{singleDescription}
|
|
||||||
{` `}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Trans>
|
|
||||||
For <strong className="text-foreground">{min}</strong>{" "}
|
|
||||||
<Plural value={min} one="minute" other="minutes" />
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Slider
|
|
||||||
aria-labelledby={`v${name}`}
|
|
||||||
defaultValue={[min]}
|
|
||||||
onValueCommit={(min) => {
|
|
||||||
data.updateAlert?.(true, value, min[0])
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setMin(val[0])
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,150 +1,91 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import {
|
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
import { ChartData, SystemStatsRecord } from "@/types"
|
||||||
cn,
|
import { useMemo } from "react"
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
decimalString,
|
|
||||||
chartMargin,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
|
|
||||||
/** [label, key, color, opacity] */
|
export type DataPoint = {
|
||||||
type DataKeys = [string, string, number, number]
|
label: string
|
||||||
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
color: string
|
||||||
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
|
opacity: number
|
||||||
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
|
|
||||||
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
|
|
||||||
// if not, return null - there is no max data so do not display anything.
|
|
||||||
return `stats.${path}${max ? "m" : ""}`
|
|
||||||
.split(".")
|
|
||||||
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
maxToggled = false,
|
|
||||||
unit = " MB/s",
|
|
||||||
chartName,
|
|
||||||
chartData,
|
chartData,
|
||||||
max,
|
max,
|
||||||
|
maxToggled,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
}: {
|
dataPoints,
|
||||||
maxToggled?: boolean
|
}: // logRender = false,
|
||||||
unit?: string
|
{
|
||||||
chartName: string
|
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
max?: number
|
||||||
tickFormatter?: (value: number) => string
|
maxToggled?: boolean
|
||||||
contentFormatter?: (value: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
|
dataPoints?: DataPoint[]
|
||||||
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
const { chartTime } = chartData
|
return useMemo(() => {
|
||||||
|
if (chartData.systemStats.length === 0) {
|
||||||
const showMax = chartTime !== "1h" && maxToggled
|
return null
|
||||||
|
|
||||||
const dataKeys: DataKeys[] = useMemo(() => {
|
|
||||||
// [label, key, color, opacity]
|
|
||||||
if (chartName === "CPU Usage") {
|
|
||||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
|
||||||
} else if (chartName === "dio") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
|
|
||||||
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName === "bw") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
|
|
||||||
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("efs")) {
|
|
||||||
return [
|
|
||||||
[t`Write`, `${chartName}.w`, 3, 0.3],
|
|
||||||
[t`Read`, `${chartName}.r`, 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("g.")) {
|
|
||||||
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
|
|
||||||
}
|
}
|
||||||
return []
|
// if (logRender) {
|
||||||
}, [chartName, i18n.locale])
|
// console.log("Rendered at", new Date())
|
||||||
|
// }
|
||||||
// console.log('Rendered at', new Date())
|
return (
|
||||||
|
<div>
|
||||||
if (chartData.systemStats.length === 0) {
|
<ChartContainer
|
||||||
return null
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
}
|
"opacity-100": yAxisWidth,
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ChartContainer
|
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
|
||||||
"opacity-100": yAxisWidth,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
domain={[0, max ?? "auto"]}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
let val: string
|
|
||||||
if (tickFormatter) {
|
|
||||||
val = tickFormatter(value)
|
|
||||||
} else {
|
|
||||||
val = toFixedWithoutTrailingZeros(value, 2) + unit
|
|
||||||
}
|
|
||||||
return updateYAxisWidth(val)
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
{xAxis(chartData)}
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={({ value }) => {
|
|
||||||
if (contentFormatter) {
|
|
||||||
return contentFormatter(value)
|
|
||||||
}
|
|
||||||
return decimalString(value) + unit
|
|
||||||
}}
|
|
||||||
// indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{dataKeys.map((key, i) => {
|
|
||||||
const color = `hsl(var(--chart-${key[2]}))`
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={i}
|
|
||||||
dataKey={getNestedValue.bind(null, key[1], showMax)}
|
|
||||||
name={key[0]}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={color}
|
|
||||||
fillOpacity={key[3]}
|
|
||||||
stroke={color}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
>
|
||||||
</AreaChart>
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
</ChartContainer>
|
<CartesianGrid vertical={false} />
|
||||||
</div>
|
<YAxis
|
||||||
)
|
direction="ltr"
|
||||||
})
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
|
domain={[0, max ?? "auto"]}
|
||||||
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={contentFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{dataPoints?.map((dataPoint, i) => {
|
||||||
|
const color = `hsl(var(--chart-${dataPoint.color}))`
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={i}
|
||||||
|
dataKey={dataPoint.dataKey}
|
||||||
|
name={dataPoint.label}
|
||||||
|
type="monotoneX"
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={dataPoint.opacity}
|
||||||
|
stroke={color}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import {
|
import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
decimalString,
|
|
||||||
chartMargin,
|
|
||||||
toFixedFloat,
|
|
||||||
getSizeAndUnit,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $containerFilter } from "@/lib/stores"
|
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { ChartType } from "@/lib/enums"
|
import { ChartType, Unit } from "@/lib/enums"
|
||||||
|
|
||||||
export default memo(function ContainerChart({
|
export default memo(function ContainerChart({
|
||||||
dataKey,
|
dataKey,
|
||||||
@@ -30,6 +21,7 @@ export default memo(function ContainerChart({
|
|||||||
unit?: string
|
unit?: string
|
||||||
}) {
|
}) {
|
||||||
const filter = useStore($containerFilter)
|
const filter = useStore($containerFilter)
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
const { containerData } = chartData
|
const { containerData } = chartData
|
||||||
@@ -37,41 +29,36 @@ export default memo(function ContainerChart({
|
|||||||
const isNetChart = chartType === ChartType.Network
|
const isNetChart = chartType === ChartType.Network
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
const config = {} as Record<string, { label: string; color: string }>
|
||||||
string,
|
const totalUsage = new Map<string, number>()
|
||||||
{
|
|
||||||
label: string
|
// calculate total usage of each container
|
||||||
color: string
|
for (const stats of containerData) {
|
||||||
}
|
for (const key in stats) {
|
||||||
>
|
if (!key || key === "created") continue
|
||||||
const totalUsage = {} as Record<string, number>
|
|
||||||
for (let stats of containerData) {
|
const currentTotal = totalUsage.get(key) ?? 0
|
||||||
for (let key in stats) {
|
const increment = isNetChart
|
||||||
if (!key || key === "created") {
|
? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
||||||
continue
|
: // @ts-ignore
|
||||||
}
|
stats[key]?.[dataKey] ?? 0
|
||||||
if (!(key in totalUsage)) {
|
|
||||||
totalUsage[key] = 0
|
totalUsage.set(key, currentTotal + increment)
|
||||||
}
|
|
||||||
if (isNetChart) {
|
|
||||||
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
totalUsage[key] += stats[key]?.[dataKey] ?? 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let keys = Object.keys(totalUsage)
|
|
||||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
// Sort keys and generate colors based on usage
|
||||||
const length = keys.length
|
const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a)
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const key = keys[i]
|
const length = sortedEntries.length
|
||||||
|
sortedEntries.forEach(([key], i) => {
|
||||||
const hue = ((i * 360) / length) % 360
|
const hue = ((i * 360) / length) % 360
|
||||||
config[key] = {
|
config[key] = {
|
||||||
label: key,
|
label: key,
|
||||||
color: `hsl(${hue}, 60%, 55%)`,
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
return config satisfies ChartConfig
|
return config satisfies ChartConfig
|
||||||
}, [chartData])
|
}, [chartData])
|
||||||
|
|
||||||
@@ -84,13 +71,14 @@ export default memo(function ContainerChart({
|
|||||||
// tick formatter
|
// tick formatter
|
||||||
if (chartType === ChartType.CPU) {
|
if (chartType === ChartType.CPU) {
|
||||||
obj.tickFormatter = (value) => {
|
obj.tickFormatter = (value) => {
|
||||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
const val = toFixedFloat(value, 2) + unit
|
||||||
return updateYAxisWidth(val)
|
return updateYAxisWidth(val)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
obj.tickFormatter = (value) => {
|
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
|
||||||
const { v, u } = getSizeAndUnit(value, false)
|
obj.tickFormatter = (val) => {
|
||||||
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
|
const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
|
||||||
|
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// tooltip formatter
|
// tooltip formatter
|
||||||
@@ -99,12 +87,14 @@ export default memo(function ContainerChart({
|
|||||||
try {
|
try {
|
||||||
const sent = item?.payload?.[key]?.ns ?? 0
|
const sent = item?.payload?.[key]?.ns ?? 0
|
||||||
const received = item?.payload?.[key]?.nr ?? 0
|
const received = item?.payload?.[key]?.nr ?? 0
|
||||||
|
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
|
||||||
|
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
|
||||||
return (
|
return (
|
||||||
<span className="flex">
|
<span className="flex">
|
||||||
{decimalString(received)} MB/s
|
{decimalString(receivedValue)} {receivedUnit}
|
||||||
<span className="opacity-70 ms-0.5"> rx </span>
|
<span className="opacity-70 ms-0.5"> rx </span>
|
||||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||||
{decimalString(sent)} MB/s
|
{decimalString(sentValue)} {sentUnit}
|
||||||
<span className="opacity-70 ms-0.5"> tx</span>
|
<span className="opacity-70 ms-0.5"> tx</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -114,8 +104,8 @@ export default memo(function ContainerChart({
|
|||||||
}
|
}
|
||||||
} else if (chartType === ChartType.Memory) {
|
} else if (chartType === ChartType.Memory) {
|
||||||
obj.toolTipFormatter = (item: any) => {
|
obj.toolTipFormatter = (item: any) => {
|
||||||
const { v, u } = getSizeAndUnit(item.value, false)
|
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
|
||||||
return decimalString(v, 2) + u
|
return decimalString(value) + " " + unit
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
||||||
@@ -129,6 +119,8 @@ export default memo(function ContainerChart({
|
|||||||
return obj
|
return obj
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const filterLower = filter?.toLowerCase()
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
if (containerData.length === 0) {
|
if (containerData.length === 0) {
|
||||||
@@ -170,7 +162,7 @@ export default memo(function ContainerChart({
|
|||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{Object.keys(chartConfig).map((key) => {
|
||||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
const filtered = filterLower && !key.toLowerCase().includes(filterLower)
|
||||||
let fillOpacity = filtered ? 0.05 : 0.4
|
let fillOpacity = filtered ? 0.05 : 0.4
|
||||||
let strokeOpacity = filtered ? 0.1 : 1
|
let strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import {
|
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
decimalString,
|
|
||||||
toFixedFloat,
|
|
||||||
chartMargin,
|
|
||||||
getSizeAndUnit,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { Unit } from "@/lib/enums"
|
||||||
|
|
||||||
export default memo(function DiskChart({
|
export default memo(function DiskChart({
|
||||||
dataKey,
|
dataKey,
|
||||||
@@ -53,9 +46,9 @@ export default memo(function DiskChart({
|
|||||||
minTickGap={6}
|
minTickGap={6}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(val) => {
|
||||||
const { v, u } = getSizeAndUnit(value)
|
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||||
return updateYAxisWidth(toFixedFloat(v, 2) + u)
|
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData)}
|
||||||
@@ -66,8 +59,8 @@ export default memo(function DiskChart({
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
const { v, u } = getSizeAndUnit(value)
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
return decimalString(v) + u
|
return decimalString(convertedValue) + " " + unit
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import {
|
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
decimalString,
|
|
||||||
chartMargin,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
|
|
||||||
@@ -72,7 +65,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
domain={[0, "auto"]}
|
domain={[0, "auto"]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const val = toFixedWithoutTrailingZeros(value, 2)
|
const val = toFixedFloat(value, 2)
|
||||||
return updateYAxisWidth(val + "W")
|
return updateYAxisWidth(val + "W")
|
||||||
}}
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
|
|||||||
96
beszel/site/src/components/charts/load-average-chart.tsx
Normal file
96
beszel/site/src/components/charts/load-average-chart.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
xAxis,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||||
|
import { ChartData, SystemStats } from "@/types"
|
||||||
|
import { memo } from "react"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
|
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
|
const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
|
||||||
|
{
|
||||||
|
legacy: "l1",
|
||||||
|
color: "hsl(271, 81%, 60%)", // Purple
|
||||||
|
label: t({ message: `1 min`, comment: "Load average" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacy: "l5",
|
||||||
|
color: "hsl(217, 91%, 60%)", // Blue
|
||||||
|
label: t({ message: `5 min`, comment: "Load average" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacy: "l15",
|
||||||
|
color: "hsl(25, 95%, 53%)", // Orange
|
||||||
|
label: t({ message: `15 min`, comment: "Load average" }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
return updateYAxisWidth(String(toFixedFloat(value, 2)))
|
||||||
|
}}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-ignore
|
||||||
|
// itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={(item) => decimalString(item.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{keys.map(({ legacy, color, label }, i) => {
|
||||||
|
const dataKey = (value: { stats: SystemStats }) => {
|
||||||
|
if (chartData.agentVersion.patch < 1) {
|
||||||
|
return value.stats?.[legacy]
|
||||||
|
}
|
||||||
|
return value.stats?.la?.[i] ?? value.stats?.[legacy]
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={i}
|
||||||
|
dataKey={dataKey}
|
||||||
|
name={label}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={color}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
|
import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { Unit } from "@/lib/enums"
|
||||||
|
|
||||||
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
@@ -39,8 +40,8 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const val = toFixedFloat(value, 1)
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
return updateYAxisWidth(val + " GB")
|
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -54,8 +55,11 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
itemSorter={(a, b) => a.order - b.order}
|
itemSorter={(a, b) => a.order - b.order}
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
contentFormatter={({ value }) => {
|
||||||
// indicator="line"
|
// mem values are supplied as GB
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import {
|
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
decimalString,
|
|
||||||
chartMargin,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
|
import { $userSettings } from "@/lib/stores"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
|
||||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -33,11 +29,14 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
|
|||||||
direction="ltr"
|
direction="ltr"
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
|
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => updateYAxisWidth(value + " GB")}
|
tickFormatter={(value) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
|
||||||
|
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -46,7 +45,11 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
contentFormatter={({ value }) => {
|
||||||
|
// mem values are supplied as GB
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
|
||||||
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
|
}}
|
||||||
// indicator="line"
|
// indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,19 @@ import {
|
|||||||
useYAxisWidth,
|
useYAxisWidth,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedFloat,
|
||||||
decimalString,
|
|
||||||
chartMargin,
|
chartMargin,
|
||||||
|
formatTemperature,
|
||||||
|
decimalString,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import { $temperatureFilter } from "@/lib/stores"
|
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
|
|
||||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||||
const filter = useStore($temperatureFilter)
|
const filter = useStore($temperatureFilter)
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
@@ -72,9 +74,9 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={[0, "auto"]}
|
domain={[0, "auto"]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(val) => {
|
||||||
const val = toFixedWithoutTrailingZeros(value, 2)
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
return updateYAxisWidth(val + " °C")
|
return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
|
||||||
}}
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -88,7 +90,10 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
contentFormatter={(item) => {
|
||||||
|
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
|
||||||
|
return decimalString(value) + " " + unit
|
||||||
|
}}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AlertOctagonIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
@@ -69,7 +70,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="me-2 h-4 w-4" />
|
<Server className="me-2 size-4" />
|
||||||
<span>{system.name}</span>
|
<span>{system.name}</span>
|
||||||
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -86,7 +87,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="me-2 h-4 w-4" />
|
<LayoutDashboard className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Dashboard</Trans>
|
<Trans>Dashboard</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -100,7 +101,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="me-2 h-4 w-4" />
|
<SettingsIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Settings</Trans>
|
<Trans>Settings</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -113,7 +114,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Notifications</Trans>
|
<Trans>Notifications</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -125,19 +126,31 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FingerprintIcon className="me-2 h-4 w-4" />
|
<FingerprintIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Tokens & Fingerprints</Trans>
|
<Trans>Tokens & Fingerprints</Trans>
|
||||||
</span>
|
</span>
|
||||||
{SettingsShortcut}
|
{SettingsShortcut}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(getPagePath($router, "settings", { name: "alert-history" }))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertOctagonIcon className="me-2 size-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Alert History</Trans>
|
||||||
|
</span>
|
||||||
|
{SettingsShortcut}
|
||||||
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["help", "oauth", "oidc"]}
|
keywords={["help", "oauth", "oidc"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookIcon className="me-2 h-4 w-4" />
|
<BookIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Documentation</Trans>
|
<Trans>Documentation</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -155,7 +168,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/"), "_blank")
|
window.open(prependBasePath("/_/"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UsersIcon className="me-2 h-4 w-4" />
|
<UsersIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Users</Trans>
|
<Trans>Users</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -167,7 +180,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/#/logs"), "_blank")
|
window.open(prependBasePath("/_/#/logs"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogsIcon className="me-2 h-4 w-4" />
|
<LogsIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Logs</Trans>
|
<Trans>Logs</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -179,7 +192,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
<DatabaseBackupIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Backups</Trans>
|
<Trans>Backups</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -192,7 +205,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>SMTP settings</Trans>
|
<Trans>SMTP settings</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
|
|||||||
import { copyToClipboard, getHubURL } from "@/lib/utils"
|
import { copyToClipboard, getHubURL } from "@/lib/utils"
|
||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
|
|
||||||
const isBeta = BESZEL.HUB_VERSION.includes("beta")
|
// const isbeta = beszel.hub_version.includes("beta")
|
||||||
const imageTag = isBeta ? ":edge" : ""
|
// const imagetag = isbeta ? ":edge" : ""
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the URL of the script to install the agent.
|
* Get the URL of the script to install the agent.
|
||||||
@@ -12,18 +12,20 @@ const imageTag = isBeta ? ":edge" : ""
|
|||||||
* @returns The URL for the script.
|
* @returns The URL for the script.
|
||||||
*/
|
*/
|
||||||
const getScriptUrl = (path: string = "") => {
|
const getScriptUrl = (path: string = "") => {
|
||||||
const url = new URL("https://get.beszel.dev")
|
return `https://get.beszel.dev${path}`
|
||||||
url.pathname = path
|
// no beta for now
|
||||||
if (isBeta) {
|
// const url = new URL("https://get.beszel.dev")
|
||||||
url.searchParams.set("beta", "1")
|
// url.pathname = path
|
||||||
}
|
// if (isBeta) {
|
||||||
return url.toString()
|
// url.searchParams.set("beta", "1")
|
||||||
|
// }
|
||||||
|
// return url.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyDockerCompose(port = "45876", publicKey: string, token: string) {
|
export function copyDockerCompose(port = "45876", publicKey: string, token: string) {
|
||||||
copyToClipboard(`services:
|
copyToClipboard(`services:
|
||||||
beszel-agent:
|
beszel-agent:
|
||||||
image: henrygd/beszel-agent${imageTag}
|
image: henrygd/beszel-agent
|
||||||
container_name: beszel-agent
|
container_name: beszel-agent
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -41,7 +43,7 @@ export function copyDockerCompose(port = "45876", publicKey: string, token: stri
|
|||||||
|
|
||||||
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
|
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent${imageTag}`
|
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -43,6 +43,14 @@ const showLoginFaliedToast = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAuthProviderIcon = (provider: AuthProviderInfo) => {
|
||||||
|
let { name } = provider
|
||||||
|
if (name.startsWith("oidc")) {
|
||||||
|
name = "oidc"
|
||||||
|
}
|
||||||
|
return prependBasePath(`/_/images/oauth2/${name}.svg`)
|
||||||
|
}
|
||||||
|
|
||||||
export function UserAuthForm({
|
export function UserAuthForm({
|
||||||
className,
|
className,
|
||||||
isFirstRun,
|
isFirstRun,
|
||||||
@@ -165,8 +173,8 @@ export function UserAuthForm({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
{passwordEnabled && (
|
{passwordEnabled && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
@@ -242,20 +250,20 @@ export function UserAuthForm({
|
|||||||
</form>
|
</form>
|
||||||
{(isFirstRun || oauthEnabled) && (
|
{(isFirstRun || oauthEnabled) && (
|
||||||
// only show 'continue with' during onboarding or if we have auth providers
|
// only show 'continue with' during onboarding or if we have auth providers
|
||||||
(<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t" />
|
<span className="w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
<span className="bg-background px-2 text-muted-foreground">
|
||||||
<Trans>Or continue with</Trans>
|
<Trans>Or continue with</Trans>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{oauthEnabled && (
|
{oauthEnabled && (
|
||||||
<div className="grid gap-2 -mt-1">
|
<div className="grid gap-2 -mt-1">
|
||||||
{authMethods.oauth2.providers.map((provider) => (
|
{authMethods.oauth2.providers.map((provider) => (
|
||||||
<button
|
<button
|
||||||
@@ -273,7 +281,7 @@ export function UserAuthForm({
|
|||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
||||||
src={prependBasePath(`/_/images/oauth2/${provider.name}.svg`)}
|
src={getAuthProviderIcon(provider)}
|
||||||
alt=""
|
alt=""
|
||||||
// onError={(e) => {
|
// onError={(e) => {
|
||||||
// e.currentTarget.src = "/static/lock.svg"
|
// e.currentTarget.src = "/static/lock.svg"
|
||||||
@@ -285,16 +293,16 @@ export function UserAuthForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!oauthEnabled && isFirstRun && (
|
{!oauthEnabled && isFirstRun && (
|
||||||
// only show GitHub button / dialog during onboarding
|
// only show GitHub button / dialog during onboarding
|
||||||
(<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
|
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
|
||||||
<span className="translate-y-[1px]">GitHub</span>
|
<span className="translate-y-[1px]">GitHub</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>OAuth 2 / OIDC support</Trans>
|
<Trans>OAuth 2 / OIDC support</Trans>
|
||||||
@@ -318,9 +326,9 @@ export function UserAuthForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>)
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
{passwordEnabled && !isFirstRun && (
|
{passwordEnabled && !isFirstRun && (
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "forgot_password")}
|
href={getPagePath($router, "forgot_password")}
|
||||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
@@ -328,6 +336,6 @@ export function UserAuthForm({
|
|||||||
<Trans>Forgot password?</Trans>
|
<Trans>Forgot password?</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { $alerts, $systems, pb } from "@/lib/stores"
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { GithubIcon } from "lucide-react"
|
import { GithubIcon } from "lucide-react"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
import { alertInfo, getSystemNameFromId, updateRecordList, updateSystemList } from "@/lib/utils"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { AlertRecord, SystemRecord } from "@/types"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
@@ -14,24 +14,8 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
||||||
|
|
||||||
export const Home = memo(() => {
|
export const Home = memo(() => {
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const systems = useStore($systems)
|
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
let alertsKey = ""
|
|
||||||
const activeAlerts = useMemo(() => {
|
|
||||||
const activeAlerts = alerts.filter((alert) => {
|
|
||||||
const active = alert.triggered && alert.name in alertInfo
|
|
||||||
if (!active) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
|
||||||
alertsKey += alert.id
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return activeAlerts
|
|
||||||
}, [systems, alerts])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t`Dashboard` + " / Beszel"
|
document.title = t`Dashboard` + " / Beszel"
|
||||||
}, [t])
|
}, [t])
|
||||||
@@ -44,20 +28,15 @@ export const Home = memo(() => {
|
|||||||
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
||||||
updateRecordList(e, $systems)
|
updateRecordList(e, $systems)
|
||||||
})
|
})
|
||||||
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
|
|
||||||
updateRecordList(e, $alerts)
|
|
||||||
})
|
|
||||||
return () => {
|
return () => {
|
||||||
pb.collection("systems").unsubscribe("*")
|
pb.collection("systems").unsubscribe("*")
|
||||||
// pb.collection('alerts').unsubscribe('*')
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
{/* show active alerts */}
|
<ActiveAlerts />
|
||||||
{activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<SystemsTable />
|
<SystemsTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -81,51 +60,79 @@ export const Home = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[alertsKey]
|
[]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
|
const ActiveAlerts = () => {
|
||||||
return (
|
const alerts = useStore($alerts)
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
<div className="px-2 sm:px-1">
|
const activeAlerts: AlertRecord[] = []
|
||||||
<CardTitle>
|
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||||
<Trans>Active Alerts</Trans>
|
const alertsKey: string[] = []
|
||||||
</CardTitle>
|
|
||||||
</div>
|
for (const systemId of Object.keys(alerts)) {
|
||||||
</CardHeader>
|
for (const alert of alerts[systemId].values()) {
|
||||||
<CardContent className="max-sm:p-2">
|
if (alert.triggered && alert.name in alertInfo) {
|
||||||
{activeAlerts.length > 0 && (
|
activeAlerts.push(alert)
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||||
{activeAlerts.map((alert) => {
|
}
|
||||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
}
|
||||||
return (
|
}
|
||||||
<Alert
|
|
||||||
key={alert.id}
|
return { activeAlerts, alertsKey }
|
||||||
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
|
}, [alerts])
|
||||||
>
|
|
||||||
<info.icon className="h-4 w-4" />
|
return useMemo(() => {
|
||||||
<AlertTitle>
|
if (activeAlerts.length === 0) {
|
||||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
return null
|
||||||
</AlertTitle>
|
}
|
||||||
<AlertDescription>
|
return (
|
||||||
<Trans>
|
<Card className="mb-4">
|
||||||
Exceeds {alert.value}
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
<div className="px-2 sm:px-1">
|
||||||
</Trans>
|
<CardTitle>
|
||||||
</AlertDescription>
|
<Trans>Active Alerts</Trans>
|
||||||
<Link
|
</CardTitle>
|
||||||
href={getPagePath($router, "system", { name: alert.sysname! })}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
aria-label="View system"
|
|
||||||
></Link>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent className="max-sm:p-2">
|
||||||
</Card>
|
{activeAlerts.length > 0 && (
|
||||||
)
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||||
})
|
{activeAlerts.map((alert) => {
|
||||||
|
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
key={alert.id}
|
||||||
|
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
|
||||||
|
>
|
||||||
|
<info.icon className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{alert.name === "Status" ? (
|
||||||
|
<Trans>Connection is down</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Exceeds {alert.value}
|
||||||
|
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
aria-label="View system"
|
||||||
|
></Link>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}, [alertsKey.join("")])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { pb } from "@/lib/stores"
|
||||||
|
import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
|
||||||
|
import { AlertsHistoryRecord } from "@/types"
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
useReactTable,
|
||||||
|
flexRender,
|
||||||
|
ColumnFiltersState,
|
||||||
|
SortingState,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { memo, useEffect, useState } from "react"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
|
const SectionIntro = memo(() => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">
|
||||||
|
<Trans>Alert History</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>View your 200 most recent alerts.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function AlertsHistoryDataTable() {
|
||||||
|
const [data, setData] = useState<AlertsHistoryRecord[]>([])
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
const pbOptions = {
|
||||||
|
expand: "system",
|
||||||
|
fields: "id,name,value,state,created,resolved,expand.system.name",
|
||||||
|
}
|
||||||
|
// Initial load
|
||||||
|
pb.collection<AlertsHistoryRecord>("alerts_history")
|
||||||
|
.getList(0, 200, {
|
||||||
|
...pbOptions,
|
||||||
|
sort: "-created",
|
||||||
|
})
|
||||||
|
.then(({ items }) => setData(items))
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
;(async () => {
|
||||||
|
unsubscribe = await pb.collection("alerts_history").subscribe(
|
||||||
|
"*",
|
||||||
|
(e) => {
|
||||||
|
if (e.action === "create") {
|
||||||
|
setData((current) => [e.record as AlertsHistoryRecord, ...current])
|
||||||
|
}
|
||||||
|
if (e.action === "update") {
|
||||||
|
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
|
||||||
|
}
|
||||||
|
if (e.action === "delete") {
|
||||||
|
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pbOptions
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
// Unsubscribe on unmount
|
||||||
|
return () => unsubscribe?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
className="ms-2"
|
||||||
|
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Select row"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
...alertsHistoryColumns,
|
||||||
|
],
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
const system = row.original.expand?.system?.name ?? ""
|
||||||
|
const name = row.getValue("name") ?? ""
|
||||||
|
const created = row.getValue("created") ?? ""
|
||||||
|
const search = String(filterValue).toLowerCase()
|
||||||
|
return (
|
||||||
|
system.toLowerCase().includes(search) ||
|
||||||
|
(name as string).toLowerCase().includes(search) ||
|
||||||
|
(created as string).toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bulk delete handler
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
|
||||||
|
try {
|
||||||
|
let batch = pb.createBatch()
|
||||||
|
let inBatch = 0
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
batch.collection("alerts_history").delete(id)
|
||||||
|
inBatch++
|
||||||
|
if (inBatch > 20) {
|
||||||
|
await batch.send()
|
||||||
|
batch = pb.createBatch()
|
||||||
|
inBatch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inBatch && (await batch.send())
|
||||||
|
table.resetRowSelection()
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: `Failed to delete records.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export to CSV handler
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
if (!selectedRows.length) return
|
||||||
|
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
|
||||||
|
system: (record) => record.expand?.system?.name || record.system,
|
||||||
|
name: (record) => alertInfo[record.name]?.name() || record.name,
|
||||||
|
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
|
||||||
|
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
|
||||||
|
created: (record) => formatShortDate(record.created),
|
||||||
|
resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
|
||||||
|
duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
|
||||||
|
}
|
||||||
|
const csvRows = [Object.keys(cells).join(",")]
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
const r = row.original
|
||||||
|
csvRows.push(
|
||||||
|
Object.values(cells)
|
||||||
|
.map((val) => val(r))
|
||||||
|
.join(",")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = "alerts_history.csv"
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container w-full">
|
||||||
|
<div className="@3xl:flex items-end mb-4 gap-4">
|
||||||
|
<SectionIntro />
|
||||||
|
<div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||||
|
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="h-9 shrink-0">
|
||||||
|
<Trash2Icon className="size-4 shrink-0" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>This will permanently delete all selected records from the database.</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button variant="outline" className="h-10" onClick={handleExportCSV}>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Export</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="px-4 w-full max-w-full @3xl:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-3">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between ps-1 tabular-nums">
|
||||||
|
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||||
|
<Trans>
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||||
|
selected.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
|
||||||
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
|
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||||
|
<Trans>Rows per page</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 50, 100, 200].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||||
|
<Trans>
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="ms-auto flex items-center gap-2 lg:ms-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden size-9 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeftIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-9"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeftIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-9"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRightIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden size-9 lg:flex"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRightIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import { useState } from "react"
|
|||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
// import { setLang } from "@/lib/i18n"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Unit } from "@/lib/enums"
|
||||||
|
|
||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -101,6 +102,126 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans comment="Temperature / network units">Unit preferences</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Change display units for metrics.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="block" htmlFor="unitTemp">
|
||||||
|
<Trans>Temperature unit</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="unitTemp"
|
||||||
|
key={userSettings.unitTemp}
|
||||||
|
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="unitTemp">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(Unit.Celsius)}>
|
||||||
|
<Trans>Celsius (°C)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={String(Unit.Fahrenheit)}>
|
||||||
|
<Trans>Fahrenheit (°F)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="block" htmlFor="unitNet">
|
||||||
|
<Trans comment="Context: Bytes or bits">Network unit</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="unitNet"
|
||||||
|
key={userSettings.unitNet}
|
||||||
|
defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="unitNet">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(Unit.Bytes)}>
|
||||||
|
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={String(Unit.Bits)}>
|
||||||
|
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="block" htmlFor="unitDisk">
|
||||||
|
<Trans>Disk unit</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="unitDisk"
|
||||||
|
key={userSettings.unitDisk}
|
||||||
|
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="unitDisk">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(Unit.Bytes)}>
|
||||||
|
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={String(Unit.Bits)}>
|
||||||
|
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Warning thresholds</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Set percentage thresholds for meter colors.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="colorWarn">
|
||||||
|
<Trans>Warning (%)</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="colorWarn"
|
||||||
|
name="colorWarn"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="min-w-24"
|
||||||
|
defaultValue={userSettings.colorWarn ?? 65}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="colorCrit">
|
||||||
|
<Trans>Critical (%)</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="colorCrit"
|
||||||
|
name="colorCrit"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="min-w-24"
|
||||||
|
defaultValue={userSettings.colorCrit ?? 90}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
||||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||||
<Trans>Save Settings</Trans>
|
<Trans>Save Settings</Trans>
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $router } from "@/components/router.tsx"
|
import { $router } from "@/components/router.tsx"
|
||||||
import { getPagePath, redirectPage } from "@nanostores/router"
|
import { getPagePath, redirectPage } from "@nanostores/router"
|
||||||
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
|
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
|
||||||
import { $userSettings, pb } from "@/lib/stores.ts"
|
import { $userSettings, pb } from "@/lib/stores.ts"
|
||||||
import { toast } from "@/components/ui/use-toast.ts"
|
import { toast } from "@/components/ui/use-toast.ts"
|
||||||
import { UserSettings } from "@/types.js"
|
import { UserSettings } from "@/types"
|
||||||
import General from "./general.tsx"
|
import General from "./general.tsx"
|
||||||
import Notifications from "./notifications.tsx"
|
import Notifications from "./notifications.tsx"
|
||||||
import ConfigYaml from "./config-yaml.tsx"
|
import ConfigYaml from "./config-yaml.tsx"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import Fingerprints from "./tokens-fingerprints.tsx"
|
import Fingerprints from "./tokens-fingerprints.tsx"
|
||||||
|
import AlertsHistoryDataTable from "./alerts-history-data-table"
|
||||||
|
|
||||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +64,12 @@ export default function SettingsLayout() {
|
|||||||
title: t`Tokens & Fingerprints`,
|
title: t`Tokens & Fingerprints`,
|
||||||
href: getPagePath($router, "settings", { name: "tokens" }),
|
href: getPagePath($router, "settings", { name: "tokens" }),
|
||||||
icon: FingerprintIcon,
|
icon: FingerprintIcon,
|
||||||
// admin: true,
|
noReadOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Alert History`,
|
||||||
|
href: getPagePath($router, "settings", { name: "alert-history" }),
|
||||||
|
icon: AlertOctagonIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`YAML Config`,
|
title: t`YAML Config`,
|
||||||
@@ -121,5 +127,7 @@ function SettingsContent({ name }: { name: string }) {
|
|||||||
return <ConfigYaml />
|
return <ConfigYaml />
|
||||||
case "tokens":
|
case "tokens":
|
||||||
return <Fingerprints />
|
return <Fingerprints />
|
||||||
|
case "alert-history":
|
||||||
|
return <AlertsHistoryDataTable />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
|
|
||||||
const sendTestNotification = async () => {
|
const sendTestNotification = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const res = await pb.send("/api/beszel/send-test-notification", { url })
|
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||||
if ("err" in res && !res.err) {
|
if ("err" in res && !res.err) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Test notification sent`,
|
title: t`Test notification sent`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { cn, isAdmin } from "@/lib/utils"
|
import { cn, isAdmin, isReadOnlyUser } from "@/lib/utils"
|
||||||
import { buttonVariants } from "../../ui/button"
|
import { buttonVariants } from "../../ui/button"
|
||||||
import { $router, Link, navigate } from "../../router"
|
import { $router, Link, navigate } from "../../router"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -12,6 +12,7 @@ interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
|||||||
title: string
|
title: string
|
||||||
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||||
admin?: boolean
|
admin?: boolean
|
||||||
|
noReadOnly?: boolean
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
return (
|
return (
|
||||||
<SelectItem key={item.href} value={item.href}>
|
<SelectItem key={item.href} value={item.href}>
|
||||||
<span className="flex items-center gap-2 truncate">
|
<span className="flex items-center gap-2 truncate">
|
||||||
{item.icon && <item.icon className="h-4 w-4" />}
|
{item.icon && <item.icon className="size-4" />}
|
||||||
<span className="truncate">{item.title}</span>
|
<span className="truncate">{item.title}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -44,9 +45,9 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop View */}
|
{/* Desktop View */}
|
||||||
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
|
<nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
if (item.admin && !isAdmin()) {
|
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -55,11 +56,11 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
"flex items-center gap-3 justify-start truncate",
|
"flex items-center gap-3 justify-start truncate duration-50",
|
||||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
|
page?.path === item.href ? "bg-muted hover:bg-accent/70" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
|
{item.icon && <item.icon className="size-4 shrink-0" />}
|
||||||
<span className="truncate">{item.title}</span>
|
<span className="truncate">{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,21 +34,31 @@ import {
|
|||||||
InstallDropdown,
|
InstallDropdown,
|
||||||
} from "@/components/install-dropdowns"
|
} from "@/components/install-dropdowns"
|
||||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
||||||
|
import { redirectPage } from "@nanostores/router"
|
||||||
|
import { $router } from "@/components/router"
|
||||||
|
|
||||||
const pbFingerprintOptions = {
|
const pbFingerprintOptions = {
|
||||||
expand: "system",
|
expand: "system",
|
||||||
fields: "id,fingerprint,token,system,expand.system.name",
|
fields: "id,fingerprint,token,system,expand.system.name",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortFingerprints(fingerprints: FingerprintRecord[]) {
|
||||||
|
return fingerprints.sort((a, b) => a.expand.system.name.localeCompare(b.expand.system.name))
|
||||||
|
}
|
||||||
|
|
||||||
const SettingsFingerprintsPage = memo(() => {
|
const SettingsFingerprintsPage = memo(() => {
|
||||||
|
if (isReadOnlyUser()) {
|
||||||
|
redirectPage($router, "settings", { name: "general" })
|
||||||
|
}
|
||||||
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
|
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
|
||||||
|
|
||||||
// Get fingerprint records on mount
|
// Get fingerprint records on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pb.collection("fingerprints")
|
pb.collection("fingerprints")
|
||||||
.getFullList(pbFingerprintOptions)
|
.getFullList<FingerprintRecord>(pbFingerprintOptions)
|
||||||
// @ts-ignore
|
.then((prints) => {
|
||||||
.then(setFingerprints)
|
setFingerprints(sortFingerprints(prints))
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Subscribe to fingerprint updates
|
// Subscribe to fingerprint updates
|
||||||
@@ -61,7 +71,7 @@ const SettingsFingerprintsPage = memo(() => {
|
|||||||
(res) => {
|
(res) => {
|
||||||
setFingerprints((currentFingerprints) => {
|
setFingerprints((currentFingerprints) => {
|
||||||
if (res.action === "create") {
|
if (res.action === "create") {
|
||||||
return [...currentFingerprints, res.record as FingerprintRecord]
|
return sortFingerprints([...currentFingerprints, res.record as FingerprintRecord])
|
||||||
}
|
}
|
||||||
if (res.action === "update") {
|
if (res.action === "update") {
|
||||||
return currentFingerprints.map((fingerprint) => {
|
return currentFingerprints.map((fingerprint) => {
|
||||||
@@ -154,7 +164,7 @@ const SectionUniversalToken = memo(() => {
|
|||||||
or on hub restart.
|
or on hub restart.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 pl-5 pr-4 rounded-md">
|
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 ps-5 pe-4 rounded-md">
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<>
|
<>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
$temperatureFilter,
|
$temperatureFilter,
|
||||||
} from "@/lib/stores"
|
} from "@/lib/stores"
|
||||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
import { ChartType, Os } from "@/lib/enums"
|
import { ChartType, Unit, Os } from "@/lib/enums"
|
||||||
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -21,10 +21,12 @@ import ChartTimeSelect from "../charts/chart-time-select"
|
|||||||
import {
|
import {
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
|
decimalString,
|
||||||
|
formatBytes,
|
||||||
getHostDisplayValue,
|
getHostDisplayValue,
|
||||||
getPbTimestamp,
|
getPbTimestamp,
|
||||||
getSizeAndUnit,
|
|
||||||
listen,
|
listen,
|
||||||
|
parseSemVer,
|
||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
useLocalStorage,
|
useLocalStorage,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
@@ -47,6 +49,7 @@ const DiskChart = lazy(() => import("../charts/disk-chart"))
|
|||||||
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
||||||
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
||||||
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
||||||
|
const LoadAverageChart = lazy(() => import("../charts/load-average-chart"))
|
||||||
|
|
||||||
const cache = new Map<string, any>()
|
const cache = new Map<string, any>()
|
||||||
|
|
||||||
@@ -131,6 +134,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||||
const [chartLoading, setChartLoading] = useState(true)
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
const isLongerChart = chartTime !== "1h"
|
const isLongerChart = chartTime !== "1h"
|
||||||
|
const userSettings = $userSettings.get()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${name} / Beszel`
|
document.title = `${name} / Beszel`
|
||||||
@@ -188,6 +192,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
chartTime,
|
chartTime,
|
||||||
orientation: direction === "rtl" ? "right" : "left",
|
orientation: direction === "rtl" ? "right" : "left",
|
||||||
...getTimeData(chartTime, lastCreated),
|
...getTimeData(chartTime, lastCreated),
|
||||||
|
agentVersion: parseSemVer(system?.info?.v),
|
||||||
}
|
}
|
||||||
}, [systemStats, containerData, direction])
|
}, [systemStats, containerData, direction])
|
||||||
|
|
||||||
@@ -368,6 +373,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
|
|
||||||
// select field for switching between avg and max values
|
// select field for switching between avg and max values
|
||||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
|
const showMax = chartTime !== "1h" && maxValues
|
||||||
|
|
||||||
// if no data, show empty message
|
// if no data, show empty message
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
@@ -450,9 +456,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
onClick={() => setGrid(!grid)}
|
onClick={() => setGrid(!grid)}
|
||||||
>
|
>
|
||||||
{grid ? (
|
{grid ? (
|
||||||
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
|
||||||
) : (
|
) : (
|
||||||
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
|
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -472,7 +478,20 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={t`Average system-wide CPU utilization`}
|
description={t`Average system-wide CPU utilization`}
|
||||||
cornerEl={maxValSelect}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={maxValues} unit="%" />
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`CPU Usage`,
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||||
|
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
{containerFilterBar && (
|
||||||
@@ -519,7 +538,32 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={t`Throughput of root filesystem`}
|
description={t`Throughput of root filesystem`}
|
||||||
cornerEl={maxValSelect}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName="dio" maxToggled={maxValues} />
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t({ message: "Write", comment: "Disk write" }),
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
|
||||||
|
color: "3",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t({ message: "Read", comment: "Disk read" }),
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||||
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
|
||||||
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -529,7 +573,43 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
cornerEl={maxValSelect}
|
cornerEl={maxValSelect}
|
||||||
description={t`Network traffic of public interfaces`}
|
description={t`Network traffic of public interfaces`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName="bw" maxToggled={maxValues} />
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Sent`,
|
||||||
|
// use bytes if available, otherwise multiply old MB (can remove in future)
|
||||||
|
dataKey(data) {
|
||||||
|
if (showMax) {
|
||||||
|
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
||||||
|
},
|
||||||
|
color: "5",
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Received`,
|
||||||
|
dataKey(data) {
|
||||||
|
if (showMax) {
|
||||||
|
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
||||||
|
},
|
||||||
|
color: "2",
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
let { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
|
}}
|
||||||
|
contentFormatter={(data) => {
|
||||||
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
|
return decimalString(value, value >= 100 ? 1 : 2) + " " + unit
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && containerData.length > 0 && (
|
{containerFilterBar && containerData.length > 0 && (
|
||||||
@@ -563,6 +643,18 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Load Average chart */}
|
||||||
|
{chartData.agentVersion?.minor >= 12 && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Load Average`}
|
||||||
|
description={t`System load averages over time`}
|
||||||
|
>
|
||||||
|
<LoadAverageChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Temperature chart */}
|
{/* Temperature chart */}
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -594,10 +686,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
||||||
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
||||||
const sizeFormatter = (value: number, decimals?: number) => {
|
|
||||||
const { v, u } = getSizeAndUnit(value, false)
|
|
||||||
return toFixedFloat(v, decimals || 1) + u
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className="contents">
|
<div key={id} className="contents">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -606,7 +694,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
title={`${gpu.n} ${t`Usage`}`}
|
title={`${gpu.n} ${t`Usage`}`}
|
||||||
description={t`Average utilization of ${gpu.n}`}
|
description={t`Average utilization of ${gpu.n}`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName={`g.${id}.u`} unit="%" />
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||||
|
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
@@ -616,10 +716,23 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`g.${id}.mu`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||||
|
color: "2",
|
||||||
|
opacity: 0.25,
|
||||||
|
},
|
||||||
|
]}
|
||||||
max={gpu.mt}
|
max={gpu.mt}
|
||||||
tickFormatter={sizeFormatter}
|
tickFormatter={(val) => {
|
||||||
contentFormatter={(value) => sizeFormatter(value, 2)}
|
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||||
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
|
||||||
|
return decimalString(convertedValue) + " " + unit
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -653,7 +766,32 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={t`Throughput of ${extraFsName}`}
|
description={t`Throughput of ${extraFsName}`}
|
||||||
cornerEl={maxValSelect}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName={`efs.${extraFsName}`} maxToggled={maxValues} />
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Write`,
|
||||||
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
|
||||||
|
color: "3",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Read`,
|
||||||
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||||
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
|
||||||
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { SystemRecord } from "@/types"
|
||||||
|
import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||||
|
import { ClassValue } from "clsx"
|
||||||
|
import {
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
CopyIcon,
|
||||||
|
CpuIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
MemoryStickIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PauseCircleIcon,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
ServerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
WifiIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
copyToClipboard,
|
||||||
|
decimalString,
|
||||||
|
formatBytes,
|
||||||
|
formatTemperature,
|
||||||
|
getMeterState,
|
||||||
|
isReadOnlyUser,
|
||||||
|
parseSemVer,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $userSettings, pb } from "@/lib/stores"
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
|
import { useMemo, useRef, useState } from "react"
|
||||||
|
import { memo } from "react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
import { Dialog } from "../ui/dialog"
|
||||||
|
import { SystemDialog } from "../add-system"
|
||||||
|
import { AlertDialog } from "../ui/alert-dialog"
|
||||||
|
import {
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "../ui/alert-dialog"
|
||||||
|
import { buttonVariants } from "../ui/button"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { MeterState } from "@/lib/enums"
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
up: "bg-green-500",
|
||||||
|
down: "bg-red-500",
|
||||||
|
paused: "bg-primary/40",
|
||||||
|
pending: "bg-yellow-500",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param viewMode - "table" or "grid"
|
||||||
|
* @returns - Column definitions for the systems table
|
||||||
|
*/
|
||||||
|
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
size: 200,
|
||||||
|
minSize: 0,
|
||||||
|
accessorKey: "name",
|
||||||
|
id: "system",
|
||||||
|
name: () => t`System`,
|
||||||
|
filterFn: (() => {
|
||||||
|
let filterInput = ""
|
||||||
|
let filterInputLower = ""
|
||||||
|
const nameCache = new Map<string, string>()
|
||||||
|
const statusTranslations = {
|
||||||
|
up: t`Up`.toLowerCase(),
|
||||||
|
down: t`Down`.toLowerCase(),
|
||||||
|
paused: t`Paused`.toLowerCase(),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// match filter value against name or translated status
|
||||||
|
return (row, _, newFilterInput) => {
|
||||||
|
const { name, status } = row.original
|
||||||
|
if (newFilterInput !== filterInput) {
|
||||||
|
filterInput = newFilterInput
|
||||||
|
filterInputLower = newFilterInput.toLowerCase()
|
||||||
|
}
|
||||||
|
let nameLower = nameCache.get(name)
|
||||||
|
if (nameLower === undefined) {
|
||||||
|
nameLower = name.toLowerCase()
|
||||||
|
nameCache.set(name, nameLower)
|
||||||
|
}
|
||||||
|
if (nameLower.includes(filterInputLower)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
|
||||||
|
return statusLower?.includes(filterInputLower) || false
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
enableHiding: false,
|
||||||
|
invertSorting: false,
|
||||||
|
Icon: ServerIcon,
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
|
||||||
|
<IndicatorDot system={info.row.original} />
|
||||||
|
{info.getValue() as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.cpu,
|
||||||
|
id: "cpu",
|
||||||
|
name: () => t`CPU`,
|
||||||
|
cell: TableCellWithMeter,
|
||||||
|
Icon: CpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// accessorKey: "info.mp",
|
||||||
|
accessorFn: ({ info }) => info.mp,
|
||||||
|
id: "memory",
|
||||||
|
name: () => t`Memory`,
|
||||||
|
cell: TableCellWithMeter,
|
||||||
|
Icon: MemoryStickIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.dp,
|
||||||
|
id: "disk",
|
||||||
|
name: () => t`Disk`,
|
||||||
|
cell: TableCellWithMeter,
|
||||||
|
Icon: HardDriveIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.g,
|
||||||
|
id: "gpu",
|
||||||
|
name: () => "GPU",
|
||||||
|
cell: TableCellWithMeter,
|
||||||
|
Icon: GpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadAverage",
|
||||||
|
accessorFn: ({ info }) => {
|
||||||
|
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||||
|
// TODO: remove this in future release in favor of la array
|
||||||
|
if (!sum) {
|
||||||
|
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
},
|
||||||
|
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||||
|
size: 0,
|
||||||
|
Icon: HourglassIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const { info: sysInfo, status } = info.row.original
|
||||||
|
// agent version
|
||||||
|
const { minor, patch } = parseSemVer(sysInfo.v)
|
||||||
|
let loadAverages = sysInfo.la
|
||||||
|
|
||||||
|
// use legacy load averages if agent version is less than 12.1.0
|
||||||
|
if (!loadAverages || (minor === 12 && patch < 1)) {
|
||||||
|
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...loadAverages)
|
||||||
|
if (max === 0 && (status === "paused" || minor < 12)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLoad = max / (sysInfo.t ?? 1)
|
||||||
|
const threshold = getMeterState(normalizedLoad * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
||||||
|
<span
|
||||||
|
className={cn("inline-block size-2 rounded-full me-0.5", {
|
||||||
|
[STATUS_COLORS.up]: threshold === MeterState.Good,
|
||||||
|
[STATUS_COLORS.pending]: threshold === MeterState.Warn,
|
||||||
|
[STATUS_COLORS.down]: threshold === MeterState.Crit,
|
||||||
|
[STATUS_COLORS.paused]: status !== "up",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{loadAverages?.map((la, i) => (
|
||||||
|
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||||
|
id: "net",
|
||||||
|
name: () => t`Net`,
|
||||||
|
size: 0,
|
||||||
|
Icon: EthernetIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const sys = info.row.original
|
||||||
|
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||||
|
if (sys.status === "paused") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.dt,
|
||||||
|
id: "temp",
|
||||||
|
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
||||||
|
size: 50,
|
||||||
|
hideSort: true,
|
||||||
|
Icon: ThermometerIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const val = info.getValue() as number
|
||||||
|
const userSettings = useStore($userSettings, { keys: ["unitTemp"] })
|
||||||
|
if (!val) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
|
return (
|
||||||
|
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
||||||
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.v,
|
||||||
|
id: "agent",
|
||||||
|
name: () => t`Agent`,
|
||||||
|
// invertSorting: true,
|
||||||
|
size: 50,
|
||||||
|
Icon: WifiIcon,
|
||||||
|
hideSort: true,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const version = info.getValue() as string
|
||||||
|
if (!version) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const system = info.row.original
|
||||||
|
return (
|
||||||
|
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
||||||
|
<IndicatorDot
|
||||||
|
system={system}
|
||||||
|
className={
|
||||||
|
(system.status !== "up" && STATUS_COLORS.paused) ||
|
||||||
|
(version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS.up) ||
|
||||||
|
STATUS_COLORS.pending
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
// @ts-ignore
|
||||||
|
name: () => t({ message: "Actions", comment: "Table column" }),
|
||||||
|
size: 50,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end items-center gap-1 -ms-3">
|
||||||
|
<AlertButton system={row.original} />
|
||||||
|
<ActionsButton system={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as ColumnDef<SystemRecord>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
||||||
|
const { column } = context
|
||||||
|
// @ts-ignore
|
||||||
|
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 px-3 flex"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="me-2 size-4" />}
|
||||||
|
{name()}
|
||||||
|
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const val = Number(info.getValue()) || 0
|
||||||
|
const threshold = getMeterState(val)
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
|
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
|
(info.row.original.status !== "up" && STATUS_COLORS.paused) ||
|
||||||
|
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
|
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
|
STATUS_COLORS.down
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `scalex(${val / 100})`,
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
|
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
||||||
|
// style={{ marginBottom: "-1px" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
let editOpened = useRef(false)
|
||||||
|
const { t } = useLingui()
|
||||||
|
const { id, status, host, name } = system
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={"icon"} data-nolink>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{!isReadOnlyUser() && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
editOpened.current = true
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(isReadOnlyUser() && "hidden")}
|
||||||
|
onClick={() => {
|
||||||
|
pb.collection("systems").update(id, {
|
||||||
|
status: status === "paused" ? "pending" : "paused",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(name)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy name</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy host</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||||
|
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* edit dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
||||||
|
</Dialog>
|
||||||
|
{/* deletion dialog */}
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure you want to delete {name}?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This action cannot be undone. This will permanently delete all current records for {name} from the
|
||||||
|
database.
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={() => pb.collection("systems").delete(id)}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [id, status, host, name, t, deleteOpen, editOpen])
|
||||||
|
})
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CellContext,
|
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
@@ -9,14 +8,13 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
HeaderContext,
|
|
||||||
Row,
|
Row,
|
||||||
Table as TableType,
|
Table as TableType,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -29,96 +27,30 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
MoreHorizontalIcon,
|
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
MemoryStickIcon,
|
|
||||||
CopyIcon,
|
|
||||||
PauseCircleIcon,
|
|
||||||
PlayCircleIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
WifiIcon,
|
|
||||||
HardDriveIcon,
|
|
||||||
ServerIcon,
|
|
||||||
CpuIcon,
|
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
PenBoxIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useState } from "react"
|
||||||
import { $systems, pb } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
import { cn, useLocalStorage } from "@/lib/utils"
|
||||||
import AlertsButton from "../alerts/alert-button"
|
|
||||||
import { $router, Link, navigate } from "../router"
|
import { $router, Link, navigate } from "../router"
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
import { useLingui, Trans } from "@lingui/react/macro"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { ClassValue } from "clsx"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { SystemDialog } from "../add-system"
|
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
import { Dialog } from "../ui/dialog"
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
|
||||||
const val = (info.getValue() as number) || 0
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
|
||||||
<span className="min-w-8">{decimalString(val, 1)}%</span>
|
|
||||||
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
|
||||||
(info.row.original.status !== "up" && "bg-primary/30") ||
|
|
||||||
(val < 65 && "bg-green-500") ||
|
|
||||||
(val < 90 && "bg-yellow-500") ||
|
|
||||||
"bg-red-600"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
transform: `scalex(${val / 100})`,
|
|
||||||
}}
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
|
||||||
const { column } = context
|
|
||||||
// @ts-ignore
|
|
||||||
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-9 px-3 flex"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="me-2 size-4" />}
|
|
||||||
{name()}
|
|
||||||
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SystemsTable() {
|
export default function SystemsTable() {
|
||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
@@ -136,199 +68,7 @@ export default function SystemsTable() {
|
|||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columnDefs = useMemo(() => {
|
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode])
|
||||||
const statusTranslations = {
|
|
||||||
up: () => t`Up`.toLowerCase(),
|
|
||||||
down: () => t`Down`.toLowerCase(),
|
|
||||||
paused: () => t`Paused`.toLowerCase(),
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
size: 200,
|
|
||||||
minSize: 0,
|
|
||||||
accessorKey: "name",
|
|
||||||
id: "system",
|
|
||||||
name: () => t`System`,
|
|
||||||
filterFn: (row, _, filterVal) => {
|
|
||||||
const filterLower = filterVal.toLowerCase()
|
|
||||||
const { name, status } = row.original
|
|
||||||
// Check if the filter matches the name or status for this row
|
|
||||||
if (
|
|
||||||
name.toLowerCase().includes(filterLower) ||
|
|
||||||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
enableHiding: false,
|
|
||||||
invertSorting: false,
|
|
||||||
Icon: ServerIcon,
|
|
||||||
cell: (info) => (
|
|
||||||
<span className="flex gap-0.5 items-center text-base md:pe-5">
|
|
||||||
<IndicatorDot system={info.row.original} />
|
|
||||||
<Button
|
|
||||||
data-nolink
|
|
||||||
variant={"ghost"}
|
|
||||||
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
|
||||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
|
||||||
>
|
|
||||||
{info.getValue() as string}
|
|
||||||
<CopyIcon className="h-2.5 w-2.5" />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.cpu,
|
|
||||||
id: "cpu",
|
|
||||||
name: () => t`CPU`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: CpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// accessorKey: "info.mp",
|
|
||||||
accessorFn: (originalRow) => originalRow.info.mp,
|
|
||||||
id: "memory",
|
|
||||||
name: () => t`Memory`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: MemoryStickIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.dp,
|
|
||||||
id: "disk",
|
|
||||||
name: () => t`Disk`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: HardDriveIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.g,
|
|
||||||
id: "gpu",
|
|
||||||
name: () => "GPU",
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: GpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
|
||||||
id: "net",
|
|
||||||
name: () => t`Net`,
|
|
||||||
size: 50,
|
|
||||||
Icon: EthernetIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
return <span className="tabular-nums whitespace-nowrap">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.l5,
|
|
||||||
id: "l5",
|
|
||||||
name: () => t({ message: "L5", comment: "Load average 5 minutes" }),
|
|
||||||
size: 0,
|
|
||||||
hideSort: true,
|
|
||||||
Icon: HourglassIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
|
|
||||||
{decimalString(val)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.l15,
|
|
||||||
id: "l15",
|
|
||||||
name: () => t({ message: "L15", comment: "Load average 15 minutes" }),
|
|
||||||
size: 0,
|
|
||||||
hideSort: true,
|
|
||||||
Icon: HourglassIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
|
|
||||||
{decimalString(val)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.dt,
|
|
||||||
id: "temp",
|
|
||||||
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
|
||||||
size: 50,
|
|
||||||
hideSort: true,
|
|
||||||
Icon: ThermometerIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
|
||||||
{decimalString(val)} °C
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.v,
|
|
||||||
id: "agent",
|
|
||||||
name: () => t`Agent`,
|
|
||||||
// invertSorting: true,
|
|
||||||
size: 50,
|
|
||||||
Icon: WifiIcon,
|
|
||||||
hideSort: true,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const version = info.getValue() as string
|
|
||||||
if (!version) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const system = info.row.original
|
|
||||||
return (
|
|
||||||
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
|
||||||
<IndicatorDot
|
|
||||||
system={system}
|
|
||||||
className={
|
|
||||||
(system.status !== "up" && "bg-primary/30") ||
|
|
||||||
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
|
|
||||||
"bg-yellow-500"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
// @ts-ignore
|
|
||||||
name: () => t({ message: "Actions", comment: "Table column" }),
|
|
||||||
size: 50,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end items-center gap-1 -ms-3">
|
|
||||||
<AlertsButton system={row.original} />
|
|
||||||
<ActionsButton system={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] as ColumnDef<SystemRecord>[]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -531,7 +271,7 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
|
|||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead className="px-1" key={header.id}>
|
<TableHead className="px-1.5" key={header.id}>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
@@ -606,7 +346,7 @@ const SystemCard = memo(
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
{table.getColumn("actions")?.getIsVisible() && (
|
{table.getColumn("actions")?.getIsVisible() && (
|
||||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
<AlertsButton system={system} />
|
<AlertButton system={system} />
|
||||||
<ActionsButton system={system} />
|
<ActionsButton system={system} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -641,116 +381,3 @@ const SystemCard = memo(
|
|||||||
}, [system, colLength, t])
|
}, [system, colLength, t])
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
let editOpened = useRef(false)
|
|
||||||
const { t } = useLingui()
|
|
||||||
const { id, status, host, name } = system
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size={"icon"} data-nolink>
|
|
||||||
<span className="sr-only">
|
|
||||||
<Trans>Open menu</Trans>
|
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{!isReadOnlyUser() && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
editOpened.current = true
|
|
||||||
setEditOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(isReadOnlyUser() && "hidden")}
|
|
||||||
onClick={() => {
|
|
||||||
pb.collection("systems").update(id, {
|
|
||||||
status: status === "paused" ? "pending" : "paused",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === "paused" ? (
|
|
||||||
<>
|
|
||||||
<PlayCircleIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Resume</Trans>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PauseCircleIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Pause</Trans>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
|
||||||
<CopyIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Copy host</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
|
||||||
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{/* edit dialog */}
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
||||||
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
|
||||||
</Dialog>
|
|
||||||
{/* deletion dialog */}
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
<Trans>Are you sure you want to delete {name}?</Trans>
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
<Trans>
|
|
||||||
This action cannot be undone. This will permanently delete all current records for {name} from the
|
|
||||||
database.
|
|
||||||
</Trans>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={() => pb.collection("systems").delete(id)}
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [id, status, host, name, t, deleteOpen, editOpen])
|
|
||||||
})
|
|
||||||
|
|
||||||
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
|
||||||
className ||= {
|
|
||||||
"bg-green-500": system.status === "up",
|
|
||||||
"bg-red-500": system.status === "down",
|
|
||||||
"bg-primary/40": system.status === "paused",
|
|
||||||
"bg-yellow-500": system.status === "pending",
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
|
||||||
// style={{ marginBottom: "-1px" }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent/70 hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
@@ -11,13 +13,14 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="size-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
))
|
))
|
||||||
|
|||||||
49
beszel/site/src/components/ui/collapsible.tsx
Normal file
49
beszel/site/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ChevronDownIcon, HourglassIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "./button"
|
||||||
|
|
||||||
|
interface CollapsibleProps {
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
defaultOpen?: boolean
|
||||||
|
className?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Collapsible({ title, children, description, defaultOpen = false, className, icon }: CollapsibleProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(defaultOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("border rounded-lg", className)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-between p-4 font-semibold"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn("h-4 w-4 transition-transform duration-200", {
|
||||||
|
"rotate-180": isOpen,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{description && (
|
||||||
|
<div className="px-4 pb-2 text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -105,7 +105,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent/70 aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent/70 data-[state=open]:bg-accent/70",
|
||||||
inset && "ps-8",
|
inset && "ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
inset && "ps-8",
|
inset && "ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -95,7 +95,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted",
|
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 30 8% 98.5%;
|
--background: 30 8% 98%;
|
||||||
--foreground: 30 0% 0%;
|
--foreground: 30 0% 0%;
|
||||||
--card: 30 0% 100%;
|
--card: 30 0% 100%;
|
||||||
--card-foreground: 240 6.67% 2.94%;
|
--card-foreground: 240 6.67% 2.94%;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** Operating system */
|
||||||
export enum Os {
|
export enum Os {
|
||||||
Linux = 0,
|
Linux = 0,
|
||||||
Darwin,
|
Darwin,
|
||||||
@@ -5,9 +6,25 @@ export enum Os {
|
|||||||
FreeBSD,
|
FreeBSD,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Type of chart */
|
||||||
export enum ChartType {
|
export enum ChartType {
|
||||||
Memory,
|
Memory,
|
||||||
Disk,
|
Disk,
|
||||||
Network,
|
Network,
|
||||||
CPU,
|
CPU,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unit of measurement */
|
||||||
|
export enum Unit {
|
||||||
|
Bytes,
|
||||||
|
Bits,
|
||||||
|
Celsius,
|
||||||
|
Fahrenheit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meter state for color */
|
||||||
|
export enum MeterState {
|
||||||
|
Good,
|
||||||
|
Warn,
|
||||||
|
Crit,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import PocketBase from "pocketbase"
|
import PocketBase from "pocketbase"
|
||||||
import { atom, map, PreinitializedWritableAtom } from "nanostores"
|
import { atom, map } from "nanostores"
|
||||||
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
||||||
import { basePath } from "@/components/router"
|
import { basePath } from "@/components/router"
|
||||||
|
import { Unit } from "./enums"
|
||||||
|
|
||||||
/** PocketBase JS Client */
|
/** PocketBase JS Client */
|
||||||
export const pb = new PocketBase(basePath)
|
export const pb = new PocketBase(basePath)
|
||||||
@@ -10,30 +11,40 @@ export const pb = new PocketBase(basePath)
|
|||||||
export const $authenticated = atom(pb.authStore.isValid)
|
export const $authenticated = atom(pb.authStore.isValid)
|
||||||
|
|
||||||
/** List of system records */
|
/** List of system records */
|
||||||
export const $systems = atom([] as SystemRecord[])
|
export const $systems = atom<SystemRecord[]>([])
|
||||||
|
|
||||||
/** List of alert records */
|
/** Map of alert records by system id and alert name */
|
||||||
export const $alerts = atom([] as AlertRecord[])
|
export const $alerts = map<AlertMap>({})
|
||||||
|
|
||||||
/** SSH public key */
|
/** SSH public key */
|
||||||
export const $publicKey = atom("")
|
export const $publicKey = atom("")
|
||||||
|
|
||||||
/** Chart time period */
|
/** Chart time period */
|
||||||
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
|
export const $chartTime = atom<ChartTimes>("1h")
|
||||||
|
|
||||||
/** Whether to display average or max chart values */
|
/** Whether to display average or max chart values */
|
||||||
export const $maxValues = atom(false)
|
export const $maxValues = atom(false)
|
||||||
|
|
||||||
|
// export const UserSettingsSchema = v.object({
|
||||||
|
// chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]),
|
||||||
|
// emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]),
|
||||||
|
// webhooks: v.optional(v.array(v.string())),
|
||||||
|
// colorWarn: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
|
||||||
|
// colorDanger: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
|
||||||
|
// unitTemp: v.optional(v.enum(Unit)),
|
||||||
|
// unitNet: v.optional(v.enum(Unit)),
|
||||||
|
// unitDisk: v.optional(v.enum(Unit)),
|
||||||
|
// })
|
||||||
|
|
||||||
/** User settings */
|
/** User settings */
|
||||||
export const $userSettings = map<UserSettings>({
|
export const $userSettings = map<UserSettings>({
|
||||||
chartTime: "1h",
|
chartTime: "1h",
|
||||||
emails: [pb.authStore.record?.email || ""],
|
emails: [pb.authStore.record?.email || ""],
|
||||||
|
unitNet: Unit.Bytes,
|
||||||
|
unitTemp: Unit.Celsius,
|
||||||
})
|
})
|
||||||
// update local storage on change
|
// update chart time on change
|
||||||
$userSettings.subscribe((value) => {
|
$userSettings.subscribe((value) => $chartTime.set(value.chartTime))
|
||||||
// console.log('user settings changed', value)
|
|
||||||
$chartTime.set(value.chartTime)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Container chart filter */
|
/** Container chart filter */
|
||||||
export const $containerFilter = atom("")
|
export const $containerFilter = atom("")
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ import { toast } from "@/components/ui/use-toast"
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
|
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
|
||||||
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord } from "@/types"
|
import {
|
||||||
|
AlertInfo,
|
||||||
|
AlertRecord,
|
||||||
|
ChartTimeData,
|
||||||
|
ChartTimes,
|
||||||
|
FingerprintRecord,
|
||||||
|
SemVer,
|
||||||
|
SystemRecord,
|
||||||
|
UserSettings,
|
||||||
|
} from "@/types"
|
||||||
import { RecordModel, RecordSubscription } from "pocketbase"
|
import { RecordModel, RecordSubscription } from "pocketbase"
|
||||||
import { WritableAtom } from "nanostores"
|
import { WritableAtom } from "nanostores"
|
||||||
import { timeDay, timeHour } from "d3-time"
|
import { timeDay, timeHour } from "d3-time"
|
||||||
@@ -11,6 +20,7 @@ import { useEffect, useState } from "react"
|
|||||||
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
||||||
import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons"
|
import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons"
|
||||||
import { prependBasePath } from "@/components/router"
|
import { prependBasePath } from "@/components/router"
|
||||||
|
import { MeterState, Unit } from "./enums"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -73,19 +83,14 @@ export const updateSystemList = (() => {
|
|||||||
|
|
||||||
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
||||||
export async function logOut() {
|
export async function logOut() {
|
||||||
sessionStorage.setItem("lo", "t")
|
$systems.set([])
|
||||||
|
$alerts.set({})
|
||||||
|
$userSettings.set({} as UserSettings)
|
||||||
|
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
pb.realtime.unsubscribe()
|
pb.realtime.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateAlerts = () => {
|
|
||||||
pb.collection("alerts")
|
|
||||||
.getFullList<AlertRecord>({ fields: "id,name,system,value,min,triggered", sort: "updated" })
|
|
||||||
.then((records) => {
|
|
||||||
$alerts.set(records)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
|
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
@@ -225,17 +230,17 @@ export function useYAxisWidth() {
|
|||||||
return { yAxisWidth, updateYAxisWidth }
|
return { yAxisWidth, updateYAxisWidth }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toFixedWithoutTrailingZeros(num: number, digits: number) {
|
/** Format number to x decimal places, without trailing zeros */
|
||||||
return parseFloat(num.toFixed(digits)).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toFixedFloat(num: number, digits: number) {
|
export function toFixedFloat(num: number, digits: number) {
|
||||||
return parseFloat(num.toFixed(digits))
|
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
|
||||||
}
|
}
|
||||||
|
|
||||||
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
|
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
|
||||||
/** Format number to x decimal places */
|
/** Format number to x decimal places, maintaining trailing zeros */
|
||||||
export function decimalString(num: number, digits = 2) {
|
export function decimalString(num: number, digits = 2) {
|
||||||
|
if (digits === 0) {
|
||||||
|
return Math.ceil(num).toString()
|
||||||
|
}
|
||||||
let formatter = decimalFormatters.get(digits)
|
let formatter = decimalFormatters.get(digits)
|
||||||
if (!formatter) {
|
if (!formatter) {
|
||||||
formatter = new Intl.NumberFormat(undefined, {
|
formatter = new Intl.NumberFormat(undefined, {
|
||||||
@@ -266,42 +271,96 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
|
|||||||
return [value, setValue]
|
return [value, setValue]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format temperature to user's preferred unit */
|
||||||
|
export function formatTemperature(celsius: number, unit?: Unit): { value: number; unit: string } {
|
||||||
|
if (!unit) {
|
||||||
|
unit = $userSettings.get().unitTemp || Unit.Celsius
|
||||||
|
}
|
||||||
|
// need loose equality check due to form data being strings
|
||||||
|
if (unit == Unit.Fahrenheit) {
|
||||||
|
return {
|
||||||
|
value: celsius * 1.8 + 32,
|
||||||
|
unit: "°F",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: celsius,
|
||||||
|
unit: "°C",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format bytes to user's preferred unit */
|
||||||
|
export function formatBytes(
|
||||||
|
size: number,
|
||||||
|
perSecond = false,
|
||||||
|
unit = Unit.Bytes,
|
||||||
|
isMegabytes = false
|
||||||
|
): { value: number; unit: string } {
|
||||||
|
// Convert MB to bytes if isMegabytes is true
|
||||||
|
if (isMegabytes) size *= 1024 * 1024
|
||||||
|
|
||||||
|
// need loose equality check due to form data being strings
|
||||||
|
if (unit == Unit.Bits) {
|
||||||
|
const bits = size * 8
|
||||||
|
const suffix = perSecond ? "ps" : ""
|
||||||
|
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
|
||||||
|
if (bits < 1_000_000) return { value: bits / 1_000, unit: `Kb${suffix}` }
|
||||||
|
if (bits < 1_000_000_000)
|
||||||
|
return {
|
||||||
|
value: bits / 1_000_000,
|
||||||
|
unit: `Mb${suffix}`,
|
||||||
|
}
|
||||||
|
if (bits < 1_000_000_000_000)
|
||||||
|
return {
|
||||||
|
value: bits / 1_000_000_000,
|
||||||
|
unit: `Gb${suffix}`,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: bits / 1_000_000_000_000,
|
||||||
|
unit: `Tb${suffix}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// bytes
|
||||||
|
const suffix = perSecond ? "/s" : ""
|
||||||
|
if (size < 100) return { value: size, unit: `B${suffix}` }
|
||||||
|
if (size < 1000 * 1024) return { value: size / 1024, unit: `KB${suffix}` }
|
||||||
|
if (size < 1000 * 1024 ** 2)
|
||||||
|
return {
|
||||||
|
value: size / 1024 ** 2,
|
||||||
|
unit: `MB${suffix}`,
|
||||||
|
}
|
||||||
|
if (size < 1000 * 1024 ** 3)
|
||||||
|
return {
|
||||||
|
value: size / 1024 ** 3,
|
||||||
|
unit: `GB${suffix}`,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: size / 1024 ** 4,
|
||||||
|
unit: `TB${suffix}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch or create user settings in database */
|
||||||
export async function updateUserSettings() {
|
export async function updateUserSettings() {
|
||||||
try {
|
try {
|
||||||
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
|
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
|
||||||
$userSettings.set(req.settings)
|
$userSettings.set(req.settings)
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("get settings", e)
|
console.error("get settings", e)
|
||||||
}
|
}
|
||||||
// create user settings if error fetching existing
|
// create user settings if error fetching existing
|
||||||
try {
|
try {
|
||||||
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
|
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
|
||||||
$userSettings.set(createdSettings.settings)
|
$userSettings.set(createdSettings.settings)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("create settings", e)
|
console.error("create settings", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the value and unit of size (TB, GB, or MB) for a given size
|
|
||||||
* @param n size in gigabytes or megabytes
|
|
||||||
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
|
|
||||||
* @returns an object containing the value and unit of size
|
|
||||||
*/
|
|
||||||
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
|
|
||||||
const sizeInGB = isGigabytes ? n : n / 1_000
|
|
||||||
|
|
||||||
if (sizeInGB >= 1_000) {
|
|
||||||
return { v: sizeInGB / 1_000, u: " TB" }
|
|
||||||
} else if (sizeInGB >= 1) {
|
|
||||||
return { v: sizeInGB, u: " GB" }
|
|
||||||
}
|
|
||||||
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chartMargin = { top: 12 }
|
export const chartMargin = { top: 12 }
|
||||||
|
|
||||||
|
/** Alert info for each alert type */
|
||||||
export const alertInfo: Record<string, AlertInfo> = {
|
export const alertInfo: Record<string, AlertInfo> = {
|
||||||
Status: {
|
Status: {
|
||||||
name: () => t`Status`,
|
name: () => t`Status`,
|
||||||
@@ -342,6 +401,16 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
icon: ThermometerIcon,
|
icon: ThermometerIcon,
|
||||||
desc: () => t`Triggers when any sensor exceeds a threshold`,
|
desc: () => t`Triggers when any sensor exceeds a threshold`,
|
||||||
},
|
},
|
||||||
|
LoadAvg1: {
|
||||||
|
name: () => t`Load Average 1m`,
|
||||||
|
unit: "",
|
||||||
|
icon: HourglassIcon,
|
||||||
|
max: 100,
|
||||||
|
min: 0.1,
|
||||||
|
start: 10,
|
||||||
|
step: 0.1,
|
||||||
|
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
|
||||||
|
},
|
||||||
LoadAvg5: {
|
LoadAvg5: {
|
||||||
name: () => t`Load Average 5m`,
|
name: () => t`Load Average 5m`,
|
||||||
unit: "",
|
unit: "",
|
||||||
@@ -362,7 +431,7 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
step: 0.1,
|
step: 0.1,
|
||||||
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
||||||
},
|
},
|
||||||
}
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retuns value of system host, truncating full path if socket.
|
* Retuns value of system host, truncating full path if socket.
|
||||||
@@ -380,3 +449,159 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
|
|||||||
|
|
||||||
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
||||||
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
||||||
|
|
||||||
|
/** Calculate duration between two dates and format as human-readable string */
|
||||||
|
export function formatDuration(
|
||||||
|
createdDate: string | null | undefined,
|
||||||
|
resolvedDate: string | null | undefined
|
||||||
|
): string {
|
||||||
|
const created = createdDate ? new Date(createdDate) : null
|
||||||
|
const resolved = resolvedDate ? new Date(resolvedDate) : null
|
||||||
|
|
||||||
|
if (!created || !resolved) return ""
|
||||||
|
|
||||||
|
const diffMs = resolved.getTime() - created.getTime()
|
||||||
|
if (diffMs < 0) return ""
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor(diffMs / 1000)
|
||||||
|
let hours = Math.floor(totalSeconds / 3600)
|
||||||
|
let minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
// if seconds are close to 60, round up to next minute
|
||||||
|
// if minutes are close to 60, round up to next hour
|
||||||
|
if (seconds >= 58) {
|
||||||
|
minutes += 1
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
|
if (minutes >= 60) {
|
||||||
|
hours += 1
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// For durations over 1 hour, omit seconds for cleaner display
|
||||||
|
if (hours > 0) {
|
||||||
|
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null].filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, seconds ? `${seconds}s` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseSemVer = (semVer = ""): SemVer => {
|
||||||
|
// if (semVer.startsWith("v")) {
|
||||||
|
// semVer = semVer.slice(1)
|
||||||
|
// }
|
||||||
|
if (semVer.includes("-")) {
|
||||||
|
semVer = semVer.slice(0, semVer.indexOf("-"))
|
||||||
|
}
|
||||||
|
const parts = semVer.split(".").map(Number)
|
||||||
|
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get meter state from 0-100 value. Used for color coding meters. */
|
||||||
|
export function getMeterState(value: number): MeterState {
|
||||||
|
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
|
||||||
|
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => func(...args), wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* returns the name of a system from its id */
|
||||||
|
export const getSystemNameFromId = (() => {
|
||||||
|
const cache = new Map<string, string>()
|
||||||
|
return (systemId: string): string => {
|
||||||
|
if (cache.has(systemId)) {
|
||||||
|
return cache.get(systemId)!
|
||||||
|
}
|
||||||
|
const sysName = $systems.get().find((s) => s.id === systemId)?.name ?? ""
|
||||||
|
cache.set(systemId, sysName)
|
||||||
|
return sysName
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// TODO: reorganize this utils file into more specific files
|
||||||
|
/** Helper to manage user alerts */
|
||||||
|
export const alertManager = (() => {
|
||||||
|
const collection = pb.collection<AlertRecord>("alerts")
|
||||||
|
|
||||||
|
/** Fields to fetch from alerts collection */
|
||||||
|
const fields = "id,name,system,value,min,triggered"
|
||||||
|
|
||||||
|
/** Fetch alerts from collection */
|
||||||
|
async function fetchAlerts(): Promise<AlertRecord[]> {
|
||||||
|
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format alerts into a map of system id to alert name to alert record */
|
||||||
|
function add(alerts: AlertRecord[]) {
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const systemId = alert.system
|
||||||
|
const systemAlerts = $alerts.get()[systemId] ?? new Map()
|
||||||
|
const newAlerts = new Map(systemAlerts)
|
||||||
|
newAlerts.set(alert.name, alert)
|
||||||
|
$alerts.setKey(systemId, newAlerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const systemId = alert.system
|
||||||
|
const systemAlerts = $alerts.get()[systemId]
|
||||||
|
const newAlerts = new Map(systemAlerts)
|
||||||
|
newAlerts.delete(alert.name)
|
||||||
|
$alerts.setKey(systemId, newAlerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionFns = {
|
||||||
|
create: add,
|
||||||
|
update: add,
|
||||||
|
delete: remove,
|
||||||
|
}
|
||||||
|
|
||||||
|
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
|
||||||
|
const batchUpdate = (() => {
|
||||||
|
const batch = new Map<string, RecordSubscription<AlertRecord>>()
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
return (data: RecordSubscription<AlertRecord>) => {
|
||||||
|
const { record } = data
|
||||||
|
batch.set(`${record.system}${record.name}`, data)
|
||||||
|
clearTimeout(timeout!)
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
|
||||||
|
for (const { action, record } of batch.values()) {
|
||||||
|
groups[action]?.push(record)
|
||||||
|
}
|
||||||
|
for (const key in groups) {
|
||||||
|
if (groups[key].length) {
|
||||||
|
actionFns[key as keyof typeof actionFns]?.(groups[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch.clear()
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
collection.subscribe("*", batchUpdate, { fields })
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Add alerts to store */
|
||||||
|
add,
|
||||||
|
/** Remove alerts from store */
|
||||||
|
remove,
|
||||||
|
/** Refresh alerts with latest data from hub */
|
||||||
|
async refresh() {
|
||||||
|
const records = await fetchAlerts()
|
||||||
|
add(records)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: ar\n"
|
"Language: ar\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-03-06 07:27\n"
|
"PO-Revision-Date: 2025-07-25 22:44\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Arabic\n"
|
"Language-Team: Arabic\n"
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||||
@@ -23,6 +23,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# يوم} other {# أيام}}"
|
msgstr "{0, plural, one {# يوم} other {# أيام}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr "تم تحديد {0} من {1} صف"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# ساعة} other {# ساعات}}"
|
msgstr "{hours, plural, one {# ساعة} other {# ساعات}}"
|
||||||
@@ -31,6 +37,11 @@ msgstr "{hours, plural, one {# ساعة} other {# ساعات}}"
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 ساعة"
|
msgstr "1 ساعة"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr "دقيقة واحدة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 أسبوع"
|
msgstr "1 أسبوع"
|
||||||
@@ -39,6 +50,11 @@ msgstr "1 أسبوع"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 ساعة"
|
msgstr "12 ساعة"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr "15 دقيقة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 ساعة"
|
msgstr "24 ساعة"
|
||||||
@@ -47,12 +63,22 @@ msgstr "24 ساعة"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 يومًا"
|
msgstr "30 يومًا"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr "5 دقائق"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "إجراءات"
|
msgstr "إجراءات"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "نشط"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "التنبيهات النشطة"
|
msgstr "التنبيهات النشطة"
|
||||||
@@ -71,7 +97,7 @@ msgstr "إضافة نظام"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Add URL"
|
msgid "Add URL"
|
||||||
msgstr "إضافة عنوان URL"
|
msgstr "إضافة رابط"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Adjust display options for charts."
|
msgid "Adjust display options for charts."
|
||||||
@@ -82,10 +108,16 @@ msgstr "تعديل خيارات العرض للرسوم البيانية."
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "مسؤول"
|
msgstr "مسؤول"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "وكيل"
|
msgstr "وكيل"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr "سجل التنبيهات"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -96,10 +128,14 @@ msgstr "التنبيهات"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "جميع الأنظمة"
|
msgstr "جميع الأنظمة"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "هل أنت متأكد أنك تريد حذف {name}؟"
|
msgstr "هل أنت متأكد أنك تريد حذف {name}؟"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr "هل أنت متأكد؟"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "النسخ التلقائي يتطلب سياقًا آمنًا."
|
msgstr "النسخ التلقائي يتطلب سياقًا آمنًا."
|
||||||
@@ -119,7 +155,7 @@ msgstr "المتوسط يتجاوز <0>{value}{0}</0>"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "متوسط استهلاك طاقة GPUs"
|
msgstr "متوسط استهلاك طاقة وحدة معالجة الرسوميات"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
@@ -142,7 +178,7 @@ msgstr "عرض النطاق الترددي"
|
|||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
||||||
msgstr "يدعم Beszel OpenID Connect والعديد من مزودي المصادقة OAuth2."
|
msgstr "يدعم بيزيل بروتوكول OpenID Connect والعديد من مزوّدي المصادقة عبر بروتوكول OAuth2."
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
|
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
|
||||||
@@ -152,11 +188,22 @@ msgstr "يستخدم بيزيل <0>Shoutrrr</0> للتكامل مع خدمات
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "ثنائي"
|
msgstr "ثنائي"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr "بت (كيلوبت/ثانية، ميجابت/ثانية، جيجابت/ثانية)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr "بايت (كيلوبايت/ثانية، ميجابايت/ثانية، جيجابايت/ثانية)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
|
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "إلغاء"
|
msgstr "إلغاء"
|
||||||
|
|
||||||
@@ -164,6 +211,14 @@ msgstr "إلغاء"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "تحذير - فقدان محتمل للبيانات"
|
msgstr "تحذير - فقدان محتمل للبيانات"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr "درجة مئوية (°م)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr "تغيير وحدات عرض المقاييس."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "تغيير خيارات التطبيق العامة."
|
msgstr "تغيير خيارات التطبيق العامة."
|
||||||
@@ -195,14 +250,19 @@ msgstr "تعليمات سطر الأوامر"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Configure how you receive alert notifications."
|
msgid "Configure how you receive alert notifications."
|
||||||
msgstr "قم بتكوين كيفية تلقي إشعارات التنبيه."
|
msgstr "هيئ التنبيهات الواردة"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "تأكيد كلمة المرور"
|
msgstr "تأكيد كلمة المرور"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr "الاتصال مقطوع"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "متابعة"
|
msgstr "متابعة"
|
||||||
|
|
||||||
@@ -214,20 +274,20 @@ msgstr "تم النسخ إلى الحافظة"
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy docker compose file content"
|
msgctxt "Button to copy docker compose file content"
|
||||||
msgid "Copy docker compose"
|
msgid "Copy docker compose"
|
||||||
msgstr "نسخ docker compose"
|
msgstr "نسخ أمر تركيب الدوكر"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy docker run command"
|
msgctxt "Button to copy docker run command"
|
||||||
msgid "Copy docker run"
|
msgid "Copy docker run"
|
||||||
msgstr "نسخ docker run"
|
msgstr "نسخ أمر تشغيل الدوكر"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Environment variables"
|
msgctxt "Environment variables"
|
||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "نسخ متغيرات البيئة"
|
msgstr "نسخ متغيرات البيئة"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "نسخ المضيف"
|
msgstr "نسخ المضيف"
|
||||||
|
|
||||||
@@ -236,6 +296,10 @@ msgstr "نسخ المضيف"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "نسخ أمر لينكس"
|
msgstr "نسخ أمر لينكس"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "نسخ الاسم"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "نسخ النص"
|
msgstr "نسخ النص"
|
||||||
@@ -252,13 +316,13 @@ msgstr "انسخ محتوى <0>docker-compose.yml</0> للوكيل أدناه،
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "نسخ YAML"
|
msgstr "نسخ YAML"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "المعالج"
|
msgstr "المعالج"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "استخدام وحدة المعالجة المركزية"
|
msgstr "استخدام وحدة المعالجة المركزية"
|
||||||
|
|
||||||
@@ -266,6 +330,15 @@ msgstr "استخدام وحدة المعالجة المركزية"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "إنشاء حساب"
|
msgstr "إنشاء حساب"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "أنشئت"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "حرج (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -280,7 +353,8 @@ msgstr "لوحة التحكم"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "الفترة الزمنية الافتراضية"
|
msgstr "الفترة الزمنية الافتراضية"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "حذف"
|
msgstr "حذف"
|
||||||
|
|
||||||
@@ -288,7 +362,7 @@ msgstr "حذف"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "حذف البصمة"
|
msgstr "حذف البصمة"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "القرص"
|
msgstr "القرص"
|
||||||
|
|
||||||
@@ -296,6 +370,10 @@ msgstr "القرص"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "إدخال/إخراج القرص"
|
msgstr "إدخال/إخراج القرص"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr "وحدة القرص"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -308,15 +386,15 @@ msgstr "استخدام القرص لـ {extraFsName}"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
msgstr "استخدام المعالج لـ Docker"
|
msgstr "استخدام المعالج للدوكر"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Docker Memory Usage"
|
msgid "Docker Memory Usage"
|
||||||
msgstr "استخدام الذاكرة لـ Docker"
|
msgstr "استخدام الذاكرة للدوكر"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Docker Network I/O"
|
msgid "Docker Network I/O"
|
||||||
msgstr "إدخال/إخراج الشبكة لـ Docker"
|
msgstr "إدخال/إخراج الشبكة للدوكر"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Documentation"
|
msgid "Documentation"
|
||||||
@@ -324,13 +402,18 @@ msgstr "التوثيق"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
msgstr "معطل"
|
msgstr "معطل"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "المدة"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "تعديل"
|
msgstr "تعديل"
|
||||||
|
|
||||||
@@ -354,6 +437,7 @@ msgstr "أدخل عنوان البريد الإشباكي..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "خطأ"
|
msgstr "خطأ"
|
||||||
@@ -369,6 +453,10 @@ msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {#
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في <0>config.yml</0>. يرجى عمل نسخ احتياطية بانتظام."
|
msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في <0>config.yml</0>. يرجى عمل نسخ احتياطية بانتظام."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr "تصدير"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "تصدير التكوين"
|
msgstr "تصدير التكوين"
|
||||||
@@ -377,6 +465,10 @@ msgstr "تصدير التكوين"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
|
msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr "فهرنهايت (°ف)"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "فشل في المصادقة"
|
msgstr "فشل في المصادقة"
|
||||||
@@ -396,12 +488,13 @@ msgstr "فشل في تحديث التنبيه"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "تصفية..."
|
msgstr "تصفية..."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr ""
|
msgstr "البصمة"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx
|
#: src/components/alerts/alerts-system.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -419,7 +512,7 @@ msgstr "عام"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU Power Draw"
|
msgid "GPU Power Draw"
|
||||||
msgstr "استهلاك طاقة GPU"
|
msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
@@ -448,16 +541,6 @@ msgstr "عنوان البريد الإشباكي غير صالح."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "النواة"
|
msgstr "النواة"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "اللغة"
|
msgstr "اللغة"
|
||||||
@@ -471,13 +554,26 @@ msgstr "التخطيط"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "فاتح"
|
msgstr "فاتح"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr "متوسط التحميل"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr "متوسط التحميل 15 دقيقة"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr "متوسط التحميل 1 دقيقة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr "متوسط التحميل 5 دقائق"
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr "متوسط التحميل"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
@@ -512,9 +608,9 @@ msgstr "تعليمات الإعداد اليدوي"
|
|||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "1 دقيقة كحد"
|
msgstr "الحد الأقصى دقيقة"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "الذاكرة"
|
msgstr "الذاكرة"
|
||||||
|
|
||||||
@@ -525,28 +621,38 @@ msgstr "استخدام الذاكرة"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "استخدام الذاكرة لحاويات Docker"
|
msgstr "استخدام الذاكرة لحاويات دوكر"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "الاسم"
|
msgstr "الاسم"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "الشبكة"
|
msgstr "الشبكة"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Network traffic of docker containers"
|
msgid "Network traffic of docker containers"
|
||||||
msgstr "حركة مرور الشبكة لحاويات Docker"
|
msgstr "حركة مرور الشبكة لحاويات الدوكر"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "حركة مرور الشبكة للواجهات العامة"
|
msgstr "حركة مرور الشبكة للواجهات العامة"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr "وحدة الشبكة"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "لم يتم العثور على نتائج."
|
msgstr "لم يتم العثور على نتائج."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr "لا توجد نتائج."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -566,7 +672,7 @@ msgstr "دعم OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
|
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -584,6 +690,12 @@ msgstr "الكتابة فوق التنبيهات الحالية"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "صفحة"
|
msgstr "صفحة"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr "صفحة {0} من {1}"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "الصفحات / الإعدادات"
|
msgstr "الصفحات / الإعدادات"
|
||||||
@@ -605,13 +717,13 @@ msgstr "يجب أن تكون كلمة المرور أقل من 72 بايت."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
|
msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "إيقاف مؤقت"
|
msgstr "إيقاف مؤقت"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "متوقف مؤقتًا"
|
msgstr "متوقف مؤقتا"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
||||||
@@ -665,13 +777,12 @@ msgid "Public Key"
|
|||||||
msgstr "المفتاح العام"
|
msgstr "المفتاح العام"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "قراءة"
|
msgstr "قراءة"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "تم الاستلام"
|
msgstr "تم الاستلام"
|
||||||
|
|
||||||
@@ -679,7 +790,13 @@ msgstr "تم الاستلام"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "إعادة تعيين كلمة المرور"
|
msgstr "إعادة تعيين كلمة المرور"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr "تم حلها"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "استئناف"
|
msgstr "استئناف"
|
||||||
|
|
||||||
@@ -687,6 +804,10 @@ msgstr "استئناف"
|
|||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "تدوير الرمز المميز"
|
msgstr "تدوير الرمز المميز"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr "صفوف لكل صفحة"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
|
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
|
||||||
@@ -712,11 +833,14 @@ msgstr "البحث عن الأنظمة أو الإعدادات..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
|
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "تم الإرسال"
|
msgstr "تم الإرسال"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "يحدد النطاق الزمني الافتراضي للرسوم البيانية عند عرض النظام."
|
msgstr "يحدد النطاق الزمني الافتراضي للرسوم البيانية عند عرض النظام."
|
||||||
@@ -744,6 +868,11 @@ msgstr "إعدادات SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "الترتيب حسب"
|
msgstr "الترتيب حسب"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr "الحالة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "الحالة"
|
msgstr "الحالة"
|
||||||
@@ -759,11 +888,16 @@ msgstr "استخدام التبديل"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "النظام"
|
msgstr "النظام"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr "متوسط تحميل النظام مع مرور الوقت"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "الأنظمة"
|
msgstr "الأنظمة"
|
||||||
@@ -777,7 +911,7 @@ msgid "Table"
|
|||||||
msgstr "جدول"
|
msgstr "جدول"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "درجة الحرارة"
|
msgstr "درجة الحرارة"
|
||||||
|
|
||||||
@@ -786,6 +920,10 @@ msgstr "درجة الحرارة"
|
|||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "درجة الحرارة"
|
msgstr "درجة الحرارة"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr "وحدة درجة الحرارة"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "درجات حرارة مستشعرات النظام"
|
msgstr "درجات حرارة مستشعرات النظام"
|
||||||
@@ -802,10 +940,14 @@ msgstr "تم إرسال إشعار الاختبار"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
|
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذلك إلى حذف جميع السجلات الحالية لـ {name} من قاعدة البيانات بشكل دائم."
|
msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذلك إلى حذف جميع السجلات الحالية لـ {name} من قاعدة البيانات بشكل دائم."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "معدل نقل {extraFsName}"
|
msgstr "معدل نقل {extraFsName}"
|
||||||
@@ -846,13 +988,17 @@ msgstr "تسمح الرموز المميزة للوكلاء بالاتصال و
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
|
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr "يتم التفعيل عندما يتجاوز متوسط التحميل لمدة دقيقة واحدة عتبة معينة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "يتم التفعيل عندما يتجاوز متوسط التحميل لمدة 15 دقيقة عتبة معينة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 5 minute load average exceeds a threshold"
|
msgid "Triggers when 5 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "يتم التفعيل عندما يتجاوز متوسط التحميل لمدة 5 دقائق عتبة معينة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
msgid "Triggers when any sensor exceeds a threshold"
|
||||||
@@ -878,12 +1024,17 @@ msgstr "يتم التفعيل عندما يتغير الحالة بين التش
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr "تفضيلات الوحدة"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr "رمز مميز عالمي"
|
msgstr "رمز مميز عالمي"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr "قيد التشغيل"
|
msgstr "قيد التشغيل"
|
||||||
@@ -898,7 +1049,8 @@ msgstr "مدة التشغيل"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "الاستخدام"
|
msgstr "الاستخدام"
|
||||||
|
|
||||||
@@ -908,7 +1060,6 @@ msgstr "استخدام القسم الجذر"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "مستخدم"
|
msgstr "مستخدم"
|
||||||
|
|
||||||
@@ -917,10 +1068,18 @@ msgstr "مستخدم"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "المستخدمون"
|
msgstr "المستخدمون"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "القيمة"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "عرض"
|
msgstr "عرض"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr "عرض أحدث 200 تنبيه."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "الأعمدة الظاهرة"
|
msgstr "الأعمدة الظاهرة"
|
||||||
@@ -933,6 +1092,14 @@ msgstr "في انتظار وجود سجلات كافية للعرض"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحقق من <0>Crowdin</0> لمزيد من التفاصيل."
|
msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحقق من <0>Crowdin</0> لمزيد من التفاصيل."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "تحذير (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "عتبات التحذير"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "إشعارات Webhook / Push"
|
msgstr "إشعارات Webhook / Push"
|
||||||
@@ -945,11 +1112,11 @@ msgstr "عند التفعيل، يسمح هذا الرمز المميز للوك
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
msgid "Windows command"
|
msgid "Windows command"
|
||||||
msgstr "أمر Windows"
|
msgstr "أمر ويندوز"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "كتابة"
|
msgstr "كتابة"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: bg\n"
|
"Language: bg\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-03-06 07:27\n"
|
"PO-Revision-Date: 2025-07-25 22:44\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Bulgarian\n"
|
"Language-Team: Bulgarian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -23,6 +23,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# ден} other {# дни}}"
|
msgstr "{0, plural, one {# ден} other {# дни}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# час} other {# часа}}"
|
msgstr "{hours, plural, one {# час} other {# часа}}"
|
||||||
@@ -31,6 +37,11 @@ msgstr "{hours, plural, one {# час} other {# часа}}"
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 час"
|
msgstr "1 час"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 седмица"
|
msgstr "1 седмица"
|
||||||
@@ -39,6 +50,11 @@ msgstr "1 седмица"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 часа"
|
msgstr "12 часа"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 часа"
|
msgstr "24 часа"
|
||||||
@@ -47,12 +63,22 @@ msgstr "24 часа"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 дни"
|
msgstr "30 дни"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Действия"
|
msgstr "Действия"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Активни тревоги"
|
msgstr "Активни тревоги"
|
||||||
@@ -82,10 +108,16 @@ msgstr "Настрой опциите за показване на диагра
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Администратор"
|
msgstr "Администратор"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Агент"
|
msgstr "Агент"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -96,10 +128,14 @@ msgstr "Тревоги"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "Всички системи"
|
msgstr "Всички системи"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "Сигурен ли си, че искаш да изтриеш {name}?"
|
msgstr "Сигурен ли си, че искаш да изтриеш {name}?"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "Автоматичното копиране изисква защитен контескт."
|
msgstr "Автоматичното копиране изисква защитен контескт."
|
||||||
@@ -152,11 +188,22 @@ msgstr "Beszel ползва <0>Shoutrrr</0> за да се интегрира с
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Двоичен код"
|
msgstr "Двоичен код"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Кеш / Буфери"
|
msgstr "Кеш / Буфери"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Откажи"
|
msgstr "Откажи"
|
||||||
|
|
||||||
@@ -164,6 +211,14 @@ msgstr "Откажи"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Внимание - възможност за загуба на данни"
|
msgstr "Внимание - възможност за загуба на данни"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "Смени общите опции на приложението."
|
msgstr "Смени общите опции на приложението."
|
||||||
@@ -202,7 +257,12 @@ msgstr "Настрой как получаваш нотификации за т
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Потвърди парола"
|
msgstr "Потвърди парола"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Продължи"
|
msgstr "Продължи"
|
||||||
|
|
||||||
@@ -227,7 +287,7 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Копирай хоста"
|
msgstr "Копирай хоста"
|
||||||
|
|
||||||
@@ -236,6 +296,10 @@ msgstr "Копирай хоста"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Копирай linux командата"
|
msgstr "Копирай linux командата"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Копирай име"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Копирай текста"
|
msgstr "Копирай текста"
|
||||||
@@ -252,13 +316,13 @@ msgstr ""
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "Процесор"
|
msgstr "Процесор"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "Употреба на процесор"
|
msgstr "Употреба на процесор"
|
||||||
|
|
||||||
@@ -266,6 +330,15 @@ msgstr "Употреба на процесор"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Създай акаунт"
|
msgstr "Създай акаунт"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "Критично (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -280,7 +353,8 @@ msgstr "Табло"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Времеви диапазон по подразбиране"
|
msgstr "Времеви диапазон по подразбиране"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Изтрий"
|
msgstr "Изтрий"
|
||||||
|
|
||||||
@@ -288,7 +362,7 @@ msgstr "Изтрий"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Диск"
|
msgstr "Диск"
|
||||||
|
|
||||||
@@ -296,6 +370,10 @@ msgstr "Диск"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "Диск I/O"
|
msgstr "Диск I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -324,13 +402,18 @@ msgstr "Документация"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -354,6 +437,7 @@ msgstr "Въведи имейл адрес..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Грешка"
|
msgstr "Грешка"
|
||||||
@@ -369,6 +453,10 @@ msgstr "Надвишава {0}{1} в последните {2, plural, one {# м
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Съществуващи системи които не са дефинирани в <0>config.yml</0> ще бъдат изтрити. Моля прави чести архиви."
|
msgstr "Съществуващи системи които не са дефинирани в <0>config.yml</0> ще бъдат изтрити. Моля прави чести архиви."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "Експортирай конфигурация"
|
msgstr "Експортирай конфигурация"
|
||||||
@@ -377,6 +465,10 @@ msgstr "Експортирай конфигурация"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "Експортирай конфигурацията на системите."
|
msgstr "Експортирай конфигурацията на системите."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Неуспешно удостоверяване"
|
msgstr "Неуспешно удостоверяване"
|
||||||
@@ -396,6 +488,7 @@ msgstr "Неуспешно обнови тревога"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Филтрирай..."
|
msgstr "Филтрирай..."
|
||||||
|
|
||||||
@@ -448,16 +541,6 @@ msgstr "Невалиден имейл адрес."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Linux Kernel"
|
msgstr "Linux Kernel"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Език"
|
msgstr "Език"
|
||||||
@@ -471,14 +554,27 @@ msgstr "Подреждане"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Светъл"
|
msgstr "Светъл"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr "Изход"
|
msgstr "Изход"
|
||||||
@@ -514,7 +610,7 @@ msgstr ""
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Максимум 1 минута"
|
msgstr "Максимум 1 минута"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Памет"
|
msgstr "Памет"
|
||||||
|
|
||||||
@@ -527,11 +623,12 @@ msgstr "Употреба на паметта"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Използването на памет от docker контейнерите"
|
msgstr "Използването на памет от docker контейнерите"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Име"
|
msgstr "Име"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Мрежа"
|
msgstr "Мрежа"
|
||||||
|
|
||||||
@@ -543,10 +640,19 @@ msgstr "Мрежов трафик на docker контейнери"
|
|||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "Мрежов трафик на публични интерфейси"
|
msgstr "Мрежов трафик на публични интерфейси"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Няма намерени резултати."
|
msgstr "Няма намерени резултати."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -566,7 +672,7 @@ msgstr "Поддръжка на OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "На всеки рестарт, системите в датабазата ще бъдат обновени да съвпадат със системите зададени във файла."
|
msgstr "На всеки рестарт, системите в датабазата ще бъдат обновени да съвпадат със системите зададени във файла."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -584,6 +690,12 @@ msgstr "Презапиши съществуващи тревоги"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Страница"
|
msgstr "Страница"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "Страници / Настройки"
|
msgstr "Страници / Настройки"
|
||||||
@@ -605,11 +717,11 @@ msgstr ""
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Получено е искането за нулиране на паролата"
|
msgstr "Получено е искането за нулиране на паролата"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Пауза"
|
msgstr "Пауза"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "На пауза"
|
msgstr "На пауза"
|
||||||
|
|
||||||
@@ -665,13 +777,12 @@ msgid "Public Key"
|
|||||||
msgstr "Публичен ключ"
|
msgstr "Публичен ключ"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "Прочети"
|
msgstr "Прочети"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Получени"
|
msgstr "Получени"
|
||||||
|
|
||||||
@@ -679,7 +790,13 @@ msgstr "Получени"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Нулиране на парола"
|
msgstr "Нулиране на парола"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Възобнови"
|
msgstr "Възобнови"
|
||||||
|
|
||||||
@@ -687,6 +804,10 @@ msgstr "Възобнови"
|
|||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
|
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
|
||||||
@@ -712,11 +833,14 @@ msgstr "Търси за системи или настройки..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
|
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Изпратени"
|
msgstr "Изпратени"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "Задайте процентни прагове за цветовете на измервателните уреди."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "Задава диапазона за време за диаграмите, когато се разглежда система."
|
msgstr "Задава диапазона за време за диаграмите, когато се разглежда система."
|
||||||
@@ -744,6 +868,11 @@ msgstr "Настройки за SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Сортиране по"
|
msgstr "Сортиране по"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Статус"
|
msgstr "Статус"
|
||||||
@@ -759,11 +888,16 @@ msgstr "Използване на swap"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "Система"
|
msgstr "Система"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Системи"
|
msgstr "Системи"
|
||||||
@@ -777,7 +911,7 @@ msgid "Table"
|
|||||||
msgstr "Таблица"
|
msgstr "Таблица"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -786,6 +920,10 @@ msgstr ""
|
|||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "Температура"
|
msgstr "Температура"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "Температири на системни сензори"
|
msgstr "Температири на системни сензори"
|
||||||
@@ -802,10 +940,14 @@ msgstr "Тестова нотификация изпратена"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители."
|
msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "Това действие не може да бъде отменено. Това ще изтрие всички записи за {name} от датабазата."
|
msgstr "Това действие не може да бъде отменено. Това ще изтрие всички записи за {name} от датабазата."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Пропускателна способност на {extraFsName}"
|
msgstr "Пропускателна способност на {extraFsName}"
|
||||||
@@ -846,6 +988,10 @@ msgstr ""
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -878,12 +1024,17 @@ msgstr "Задейства се, когато статуса превключв
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
|
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -898,7 +1049,8 @@ msgstr "Време на работа"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Употреба"
|
msgstr "Употреба"
|
||||||
|
|
||||||
@@ -908,7 +1060,6 @@ msgstr "Употреба на root partition-а"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "Използвани"
|
msgstr "Използвани"
|
||||||
|
|
||||||
@@ -917,10 +1068,18 @@ msgstr "Използвани"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Потребители"
|
msgstr "Потребители"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Изглед"
|
msgstr "Изглед"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "Видими полета"
|
msgstr "Видими полета"
|
||||||
@@ -933,6 +1092,14 @@ msgstr "Изчаква се за достатъчно записи за пока
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Искаш да помогнеш да направиш преводите още по-добри? Провери нашия <0>Crowdin</0> за повече детайли."
|
msgstr "Искаш да помогнеш да направиш преводите още по-добри? Провери нашия <0>Crowdin</0> за повече детайли."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "Предупреждение (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "Прагове за предупреждение"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "Webhook / Пуш нотификации"
|
msgstr "Webhook / Пуш нотификации"
|
||||||
@@ -948,8 +1115,8 @@ msgid "Windows command"
|
|||||||
msgstr "Команда Windows"
|
msgstr "Команда Windows"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "Запиши"
|
msgstr "Запиши"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: cs\n"
|
"Language: cs\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-03-14 00:50\n"
|
"PO-Revision-Date: 2025-08-04 01:51\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Czech\n"
|
"Language-Team: Czech\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||||
@@ -23,6 +23,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# den} few {# dny} other {# dní}}"
|
msgstr "{0, plural, one {# den} few {# dny} other {# dní}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr "{0} z {1} vybraných řádků."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# Hodina} few {# Hodiny} many {# Hodin} other {# Hodin}}"
|
msgstr "{hours, plural, one {# Hodina} few {# Hodiny} many {# Hodin} other {# Hodin}}"
|
||||||
@@ -31,6 +37,11 @@ msgstr "{hours, plural, one {# Hodina} few {# Hodiny} many {# Hodin} other {# Ho
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 hodina"
|
msgstr "1 hodina"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 týden"
|
msgstr "1 týden"
|
||||||
@@ -39,6 +50,11 @@ msgstr "1 týden"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 hodin"
|
msgstr "12 hodin"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 hodin"
|
msgstr "24 hodin"
|
||||||
@@ -47,12 +63,22 @@ msgstr "24 hodin"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 dní"
|
msgstr "30 dní"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Akce"
|
msgstr "Akce"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Aktivní"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktivní výstrahy"
|
msgstr "Aktivní výstrahy"
|
||||||
@@ -82,10 +108,16 @@ msgstr "Upravit možnosti zobrazení pro grafy."
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Agent"
|
msgstr "Agent"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr "Historie upozornění"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -96,10 +128,14 @@ msgstr "Výstrahy"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "Všechny systémy"
|
msgstr "Všechny systémy"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "Opravdu chcete odstranit {name}?"
|
msgstr "Opravdu chcete odstranit {name}?"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr "Jste si jistý?"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "Automatická kopie vyžaduje zabezpečený kontext."
|
msgstr "Automatická kopie vyžaduje zabezpečený kontext."
|
||||||
@@ -152,11 +188,22 @@ msgstr "Beszel používá <0>Shoutrrr</0> k integraci s populárními notifikač
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binary"
|
msgstr "Binary"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Cache / vyrovnávací paměť"
|
msgstr "Cache / vyrovnávací paměť"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Zrušit"
|
msgstr "Zrušit"
|
||||||
|
|
||||||
@@ -164,6 +211,14 @@ msgstr "Zrušit"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Upozornění - možná ztráta dat"
|
msgstr "Upozornění - možná ztráta dat"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr "Celsia (°C)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr "Změnit jednotky zobrazení metrik."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "Změnit obecné nastavení aplikace."
|
msgstr "Změnit obecné nastavení aplikace."
|
||||||
@@ -202,7 +257,12 @@ msgstr "Konfigurace způsobu přijímání upozornění."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Potvrdit heslo"
|
msgstr "Potvrdit heslo"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr "Připojení je nedostupné"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Pokračovat"
|
msgstr "Pokračovat"
|
||||||
|
|
||||||
@@ -220,14 +280,14 @@ msgstr "Kopírovat docker compose"
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy docker run command"
|
msgctxt "Button to copy docker run command"
|
||||||
msgid "Copy docker run"
|
msgid "Copy docker run"
|
||||||
msgstr ""
|
msgstr "Zkopírovat příkaz na spuštění dockeru"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Environment variables"
|
msgctxt "Environment variables"
|
||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr ""
|
msgstr "Kopírovat env"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Kopírovat hostitele"
|
msgstr "Kopírovat hostitele"
|
||||||
|
|
||||||
@@ -236,29 +296,33 @@ msgstr "Kopírovat hostitele"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopírovat příkaz Linux"
|
msgstr "Kopírovat příkaz Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopírovat název"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopírovat text"
|
msgstr "Kopírovat text"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
|
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
|
||||||
msgstr ""
|
msgstr "Zkopírujte instalační příkaz pro agenta níže nebo automaticky registrujte agenty s <0>univerzálním token</0>."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
|
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
|
||||||
msgstr ""
|
msgstr "Zkopírujte obsah <0>docker-compose.yml</0> pro agenta níže nebo automaticky registrujte agenty s <1>univerzálním token</1>."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr ""
|
msgstr "Kopírovat YAML"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "Procesor"
|
msgstr "Procesor"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "Využití procesoru"
|
msgstr "Využití procesoru"
|
||||||
|
|
||||||
@@ -266,6 +330,15 @@ msgstr "Využití procesoru"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Vytvořit účet"
|
msgstr "Vytvořit účet"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "Vytvořeno"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "Kritické (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -280,15 +353,16 @@ msgstr "Přehled"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Výchozí doba"
|
msgstr "Výchozí doba"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Odstranit"
|
msgstr "Odstranit"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr ""
|
msgstr "Smazat identifikátor"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Disk"
|
msgstr "Disk"
|
||||||
|
|
||||||
@@ -296,6 +370,10 @@ msgstr "Disk"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "Disk I/O"
|
msgstr "Disk I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr "Disková jednotka"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -324,13 +402,18 @@ msgstr "Dokumentace"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
msgstr "Nefunkční"
|
msgstr "Nefunkční"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "Doba trvání"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Upravit"
|
msgstr "Upravit"
|
||||||
|
|
||||||
@@ -354,6 +437,7 @@ msgstr "Zadejte e-mailovou adresu..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Chyba"
|
msgstr "Chyba"
|
||||||
@@ -369,6 +453,10 @@ msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {posledn
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, budou odstraněny. Provádějte pravidelné zálohování."
|
msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, budou odstraněny. Provádějte pravidelné zálohování."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr "Export"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "Exportovat konfiguraci"
|
msgstr "Exportovat konfiguraci"
|
||||||
@@ -377,6 +465,10 @@ msgstr "Exportovat konfiguraci"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "Exportovat aktuální konfiguraci systémů."
|
msgstr "Exportovat aktuální konfiguraci systémů."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr "Fahrenheita (°F)"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Ověření se nezdařilo"
|
msgstr "Ověření se nezdařilo"
|
||||||
@@ -396,12 +488,13 @@ msgstr "Nepodařilo se aktualizovat upozornění"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filtr..."
|
msgstr "Filtr..."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr ""
|
msgstr "Otisk"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx
|
#: src/components/alerts/alerts-system.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -429,7 +522,7 @@ msgstr "Mřížka"
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
msgid "Homebrew command"
|
msgid "Homebrew command"
|
||||||
msgstr ""
|
msgstr "Homebrew příkaz"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Host / IP"
|
msgid "Host / IP"
|
||||||
@@ -448,16 +541,6 @@ msgstr "Neplatná e-mailová adresa."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Kernel"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Jazyk"
|
msgstr "Jazyk"
|
||||||
@@ -471,13 +554,26 @@ msgstr "Rozvržení"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Světlý"
|
msgstr "Světlý"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr "Průměrné vytížení"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr "Průměrná zátěž 15m"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr "Průměrná zátěž 1m"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr "Průměrná zátěž 5m"
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr "Prům. zatížení"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
@@ -514,7 +610,7 @@ msgstr "Pokyny k manuálnímu nastavení"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Max. 1 min"
|
msgstr "Max. 1 min"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Paměť"
|
msgstr "Paměť"
|
||||||
|
|
||||||
@@ -527,11 +623,12 @@ msgstr "Využití paměti"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Využití paměti docker kontejnerů"
|
msgstr "Využití paměti docker kontejnerů"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Název"
|
msgstr "Název"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Síť"
|
msgstr "Síť"
|
||||||
|
|
||||||
@@ -543,10 +640,19 @@ msgstr "Síťový provoz kontejnerů docker"
|
|||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "Síťový provoz veřejných rozhraní"
|
msgstr "Síťový provoz veřejných rozhraní"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr "Síťová jednotka"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Nenalezeny žádné výskyty."
|
msgstr "Nenalezeny žádné výskyty."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr "Žádné výsledky."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -566,7 +672,7 @@ msgstr "Podpora OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, aby odpovídaly systémům definovaným v souboru."
|
msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, aby odpovídaly systémům definovaným v souboru."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -584,6 +690,12 @@ msgstr "Přepsat existující upozornění"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Stránka"
|
msgstr "Stránka"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr "Stránka {0} z {1}"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "Stránky / Nastavení"
|
msgstr "Stránky / Nastavení"
|
||||||
@@ -605,11 +717,11 @@ msgstr "Heslo musí být menší než 72 bytů."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Žádost o obnovu hesla byla přijata"
|
msgstr "Žádost o obnovu hesla byla přijata"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Pozastavit"
|
msgstr "Pozastavit"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "Pozastaveno"
|
msgstr "Pozastaveno"
|
||||||
|
|
||||||
@@ -665,13 +777,12 @@ msgid "Public Key"
|
|||||||
msgstr "Veřejný klíč"
|
msgstr "Veřejný klíč"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "Číst"
|
msgstr "Číst"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Přijato"
|
msgstr "Přijato"
|
||||||
|
|
||||||
@@ -679,13 +790,23 @@ msgstr "Přijato"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Obnovit heslo"
|
msgstr "Obnovit heslo"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr "Vyřešeno"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Pokračovat"
|
msgstr "Pokračovat"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr ""
|
msgstr "Změnit token"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr "Řádků na stránku"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
@@ -712,11 +833,14 @@ msgstr "Hledat systémy nebo nastavení..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
|
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Odeslat"
|
msgstr "Odeslat"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "Nastaví výchozí časový rozsah grafů, když je systém zobrazen."
|
msgstr "Nastaví výchozí časový rozsah grafů, když je systém zobrazen."
|
||||||
@@ -744,6 +868,11 @@ msgstr "Nastavení SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Seřadit podle"
|
msgstr "Seřadit podle"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr "Stav"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Stav"
|
msgstr "Stav"
|
||||||
@@ -759,11 +888,16 @@ msgstr "Swap využití"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "Systém"
|
msgstr "Systém"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr "Průměry zatížení systému v průběhu času"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Systémy"
|
msgstr "Systémy"
|
||||||
@@ -777,7 +911,7 @@ msgid "Table"
|
|||||||
msgstr "Tabulka"
|
msgstr "Tabulka"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Teplota"
|
msgstr "Teplota"
|
||||||
|
|
||||||
@@ -786,6 +920,10 @@ msgstr "Teplota"
|
|||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "Teplota"
|
msgstr "Teplota"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr "Jednotky teploty"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "Teploty systémových senzorů"
|
msgstr "Teploty systémových senzorů"
|
||||||
@@ -802,10 +940,14 @@ msgstr "Testovací oznámení odesláno"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů."
|
msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "Tuto akci nelze vzít zpět. Tím se z databáze trvale odstraní všechny aktuální záznamy pro {name}."
|
msgstr "Tuto akci nelze vzít zpět. Tím se z databáze trvale odstraní všechny aktuální záznamy pro {name}."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr "Tímto trvale odstraníte všechny vybrané záznamy z databáze."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Propustnost {extraFsName}"
|
msgstr "Propustnost {extraFsName}"
|
||||||
@@ -830,29 +972,33 @@ msgstr "Přepnout motiv"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr ""
|
msgstr "Token"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Tokens & Fingerprints"
|
msgid "Tokens & Fingerprints"
|
||||||
msgstr ""
|
msgstr "Tokeny & Otisky"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
|
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
|
||||||
msgstr ""
|
msgstr "Tokeny umožňují agentům připojení a registraci. Otisky jsou stabilní identifikátory jedinečné pro každý systém, nastavené na první připojení."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr ""
|
msgstr "Tokeny a otisky slouží k ověření připojení WebSocket k uzlu."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr "Spustí se, když využití paměti během 1 minuty překročí prahovou hodnotu"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Spustí se, když využití paměti během 15 minut překročí prahovou hodnotu"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 5 minute load average exceeds a threshold"
|
msgid "Triggers when 5 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Spustí se, když využití paměti během 5 minut překročí prahovou hodnotu"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
msgid "Triggers when any sensor exceeds a threshold"
|
||||||
@@ -878,12 +1024,17 @@ msgstr "Spouští se, když se změní dostupnost"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
|
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr "Předvolby jednotek"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr ""
|
msgstr "Univerzální token"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr "Funkční"
|
msgstr "Funkční"
|
||||||
@@ -898,7 +1049,8 @@ msgstr "Doba provozu"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Využití"
|
msgstr "Využití"
|
||||||
|
|
||||||
@@ -908,7 +1060,6 @@ msgstr "Využití kořenového oddílu"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "Využito"
|
msgstr "Využito"
|
||||||
|
|
||||||
@@ -917,10 +1068,18 @@ msgstr "Využito"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Uživatelé"
|
msgstr "Uživatelé"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "Hodnota"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Zobrazení"
|
msgstr "Zobrazení"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr "Zobrazit vašich 200 nejnovějších upozornění."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "Viditelné sloupce"
|
msgstr "Viditelné sloupce"
|
||||||
@@ -933,23 +1092,31 @@ msgstr "Čeká se na dostatek záznamů k zobrazení"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Chcete nám pomoci s našimi překlady ještě lépe? Podívejte se na <0>Crowdin</0> pro více informací."
|
msgstr "Chcete nám pomoci s našimi překlady ještě lépe? Podívejte se na <0>Crowdin</0> pro více informací."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "Varování (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "Prahové hodnoty pro varování"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "Webhook / Push oznámení"
|
msgstr "Webhook / Push oznámení"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
|
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
|
||||||
msgstr ""
|
msgstr "Pokud je povoleno, tento token umožňuje agentům, aby se sami zaregistrovali bez předchozího vytvoření systému. Vyprší po jedné hodině nebo po restartu uzlu."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
msgid "Windows command"
|
msgid "Windows command"
|
||||||
msgstr ""
|
msgstr "Windows příkaz"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "Psát"
|
msgstr "Psát"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: da\n"
|
"Language: da\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-03-06 07:27\n"
|
"PO-Revision-Date: 2025-07-25 22:44\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Danish\n"
|
"Language-Team: Danish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -23,6 +23,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# day} other {# days}}"
|
msgstr "{0, plural, one {# day} other {# days}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# hour} other {# hours}}"
|
msgstr "{hours, plural, one {# hour} other {# hours}}"
|
||||||
@@ -31,6 +37,11 @@ msgstr "{hours, plural, one {# hour} other {# hours}}"
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 time"
|
msgstr "1 time"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 uge"
|
msgstr "1 uge"
|
||||||
@@ -39,6 +50,11 @@ msgstr "1 uge"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 timer"
|
msgstr "12 timer"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 timer"
|
msgstr "24 timer"
|
||||||
@@ -47,12 +63,22 @@ msgstr "24 timer"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 dage"
|
msgstr "30 dage"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Handlinger"
|
msgstr "Handlinger"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktive Alarmer"
|
msgstr "Aktive Alarmer"
|
||||||
@@ -82,10 +108,16 @@ msgstr "Juster visningsindstillinger for diagrammer."
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Agent"
|
msgstr "Agent"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -96,10 +128,14 @@ msgstr "Alarmer"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "Alle systemer"
|
msgstr "Alle systemer"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "Er du sikker på, at du vil slette {name}?"
|
msgstr "Er du sikker på, at du vil slette {name}?"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "Automatisk kopiering kræver en sikker kontekst."
|
msgstr "Automatisk kopiering kræver en sikker kontekst."
|
||||||
@@ -152,11 +188,22 @@ msgstr "Beszel bruger <0>Shoutrrr</0> til at integrere med populære notifikatio
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binær"
|
msgstr "Binær"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Cache / Buffere"
|
msgstr "Cache / Buffere"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Fortryd"
|
msgstr "Fortryd"
|
||||||
|
|
||||||
@@ -164,6 +211,14 @@ msgstr "Fortryd"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Forsigtig - muligt tab af data"
|
msgstr "Forsigtig - muligt tab af data"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "Skift generelle applikationsindstillinger."
|
msgstr "Skift generelle applikationsindstillinger."
|
||||||
@@ -202,7 +257,12 @@ msgstr "Konfigurer hvordan du modtager advarselsmeddelelser."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Bekræft adgangskode"
|
msgstr "Bekræft adgangskode"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Forsæt"
|
msgstr "Forsæt"
|
||||||
|
|
||||||
@@ -227,7 +287,7 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Kopier host"
|
msgstr "Kopier host"
|
||||||
|
|
||||||
@@ -236,6 +296,10 @@ msgstr "Kopier host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopier Linux kommando"
|
msgstr "Kopier Linux kommando"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopier navn"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopier tekst"
|
msgstr "Kopier tekst"
|
||||||
@@ -252,13 +316,13 @@ msgstr ""
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "CPU forbrug"
|
msgstr "CPU forbrug"
|
||||||
|
|
||||||
@@ -266,6 +330,15 @@ msgstr "CPU forbrug"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Opret konto"
|
msgstr "Opret konto"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "Kritisk (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -280,7 +353,8 @@ msgstr "Oversigtspanel"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Standard tidsperiode"
|
msgstr "Standard tidsperiode"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Slet"
|
msgstr "Slet"
|
||||||
|
|
||||||
@@ -288,7 +362,7 @@ msgstr "Slet"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Disk"
|
msgstr "Disk"
|
||||||
|
|
||||||
@@ -296,6 +370,10 @@ msgstr "Disk"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "Disk I/O"
|
msgstr "Disk I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -324,15 +402,20 @@ msgstr "Dokumentation"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
|
msgstr "Nede"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr "Rediger"
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
@@ -354,6 +437,7 @@ msgstr "Indtast e-mailadresse..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Fejl"
|
msgstr "Fejl"
|
||||||
@@ -369,6 +453,10 @@ msgstr "Overskrider {0}{1} i sidste {2, plural, one {# minut} other {# minutter}
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Eksisterende systemer ikke defineret i <0>config.yml</0> vil blive slettet. Opret venligst regelmæssige sikkerhedskopier."
|
msgstr "Eksisterende systemer ikke defineret i <0>config.yml</0> vil blive slettet. Opret venligst regelmæssige sikkerhedskopier."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "Eksporter konfiguration"
|
msgstr "Eksporter konfiguration"
|
||||||
@@ -377,6 +465,10 @@ msgstr "Eksporter konfiguration"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "Eksporter din nuværende systemkonfiguration."
|
msgstr "Eksporter din nuværende systemkonfiguration."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Kunne ikke godkende"
|
msgstr "Kunne ikke godkende"
|
||||||
@@ -396,6 +488,7 @@ msgstr "Kunne ikke opdatere alarm"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filter..."
|
msgstr "Filter..."
|
||||||
|
|
||||||
@@ -448,16 +541,6 @@ msgstr "Ugyldig email adresse."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Kernel"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Sprog"
|
msgstr "Sprog"
|
||||||
@@ -471,14 +554,27 @@ msgstr "Layout"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Lys"
|
msgstr "Lys"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr "Log ud"
|
msgstr "Log ud"
|
||||||
@@ -507,14 +603,14 @@ msgstr "Administrer display og notifikationsindstillinger."
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Manual setup instructions"
|
msgid "Manual setup instructions"
|
||||||
msgstr ""
|
msgstr "Manuel opsætningsvejledning"
|
||||||
|
|
||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Maks. 1 min"
|
msgstr "Maks. 1 min"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Hukommelse"
|
msgstr "Hukommelse"
|
||||||
|
|
||||||
@@ -527,11 +623,12 @@ msgstr "Hukommelsesforbrug"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Hukommelsesforbrug af dockercontainere"
|
msgstr "Hukommelsesforbrug af dockercontainere"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Navn"
|
msgstr "Navn"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Net"
|
msgstr "Net"
|
||||||
|
|
||||||
@@ -543,10 +640,19 @@ msgstr "Netværkstrafik af dockercontainere"
|
|||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "Netværkstrafik af offentlige grænseflader"
|
msgstr "Netværkstrafik af offentlige grænseflader"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Ingen resultater fundet."
|
msgstr "Ingen resultater fundet."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -566,7 +672,7 @@ msgstr "OAuth 2 / OIDC understøttelse"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "Ved hver genstart vil systemer i databasen blive opdateret til at matche de systemer, der er defineret i filen."
|
msgstr "Ved hver genstart vil systemer i databasen blive opdateret til at matche de systemer, der er defineret i filen."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -584,6 +690,12 @@ msgstr "Overskriv eksisterende alarmer"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Side"
|
msgstr "Side"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "Sider / Indstillinger"
|
msgstr "Sider / Indstillinger"
|
||||||
@@ -599,17 +711,17 @@ msgstr "Adgangskoden skal være på mindst 8 tegn."
|
|||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Password must be less than 72 bytes."
|
msgid "Password must be less than 72 bytes."
|
||||||
msgstr ""
|
msgstr "Adgangskoden skal være mindre end 72 bytes."
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Anmodning om nulstilling af adgangskode modtaget"
|
msgstr "Anmodning om nulstilling af adgangskode modtaget"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Pause"
|
msgstr "Pause"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "Sat på pause"
|
msgstr "Sat på pause"
|
||||||
|
|
||||||
@@ -665,13 +777,12 @@ msgid "Public Key"
|
|||||||
msgstr "Offentlig nøgle"
|
msgstr "Offentlig nøgle"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "Læs"
|
msgstr "Læs"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Modtaget"
|
msgstr "Modtaget"
|
||||||
|
|
||||||
@@ -679,7 +790,13 @@ msgstr "Modtaget"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Nulstil adgangskode"
|
msgstr "Nulstil adgangskode"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Genoptag"
|
msgstr "Genoptag"
|
||||||
|
|
||||||
@@ -687,6 +804,10 @@ msgstr "Genoptag"
|
|||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Gem adresse ved hjælp af enter eller komma. Lad feltet stå tomt for at deaktivere e-mail-meddelelser."
|
msgstr "Gem adresse ved hjælp af enter eller komma. Lad feltet stå tomt for at deaktivere e-mail-meddelelser."
|
||||||
@@ -698,7 +819,7 @@ msgstr "Gem indstillinger"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Save system"
|
msgid "Save system"
|
||||||
msgstr ""
|
msgstr "Gem system"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -712,11 +833,14 @@ msgstr "Søg efter systemer eller indstillinger..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtager alarmer."
|
msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtager alarmer."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Sendt"
|
msgstr "Sendt"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "Indstil procentvise tærskler for målerfarver."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "Sætter standardtidsintervallet for diagrammer når et system vises."
|
msgstr "Sætter standardtidsintervallet for diagrammer når et system vises."
|
||||||
@@ -744,6 +868,11 @@ msgstr "SMTP-indstillinger"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Sorter efter"
|
msgstr "Sorter efter"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Status"
|
msgstr "Status"
|
||||||
@@ -759,11 +888,16 @@ msgstr "Swap forbrug"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "System"
|
msgstr "System"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Systemer"
|
msgstr "Systemer"
|
||||||
@@ -777,15 +911,19 @@ msgid "Table"
|
|||||||
msgstr "Tabel"
|
msgstr "Tabel"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr ""
|
msgstr "Temperatur"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "Temperatur"
|
msgstr "Temperatur"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "Temperaturer i systemsensorer"
|
msgstr "Temperaturer i systemsensorer"
|
||||||
@@ -802,10 +940,14 @@ msgstr "Test notifikation sendt"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto i tabellen brugere."
|
msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto i tabellen brugere."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktuelle elementer for {name} fra databasen."
|
msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktuelle elementer for {name} fra databasen."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Gennemløb af {extraFsName}"
|
msgstr "Gennemløb af {extraFsName}"
|
||||||
@@ -846,6 +988,10 @@ msgstr ""
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -878,15 +1024,20 @@ msgstr "Udløser når status skifter mellem op og ned"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Udløser når brugen af en disk overstiger en tærskel"
|
msgstr "Udløser når brugen af en disk overstiger en tærskel"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr ""
|
msgstr "Oppe"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Updated in real time. Click on a system to view information."
|
msgid "Updated in real time. Click on a system to view information."
|
||||||
@@ -898,7 +1049,8 @@ msgstr "Oppetid"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Forbrug"
|
msgstr "Forbrug"
|
||||||
|
|
||||||
@@ -908,7 +1060,6 @@ msgstr "Brug af rodpartition"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "Brugt"
|
msgstr "Brugt"
|
||||||
|
|
||||||
@@ -917,10 +1068,18 @@ msgstr "Brugt"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Brugere"
|
msgstr "Brugere"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Vis"
|
msgstr "Vis"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "Synlige felter"
|
msgstr "Synlige felter"
|
||||||
@@ -933,6 +1092,14 @@ msgstr "Venter på nok posteringer til at vise"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Vil du hjælpe os med at gøre vores oversættelser endnu bedre? Tjek <0>Crowdin</0> for flere detaljer."
|
msgstr "Vil du hjælpe os med at gøre vores oversættelser endnu bedre? Tjek <0>Crowdin</0> for flere detaljer."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "Advarsel (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "Advarselstærskler"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "Webhook / Push notifikationer"
|
msgstr "Webhook / Push notifikationer"
|
||||||
@@ -948,8 +1115,8 @@ msgid "Windows command"
|
|||||||
msgstr "Windows-kommando"
|
msgstr "Windows-kommando"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "Skriv"
|
msgstr "Skriv"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: de\n"
|
"Language: de\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-03-06 07:27\n"
|
"PO-Revision-Date: 2025-07-25 22:44\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: German\n"
|
"Language-Team: German\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -23,6 +23,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# Tag} other {# Tage}}"
|
msgstr "{0, plural, one {# Tag} other {# Tage}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr "{0} von {1} Zeile(n) ausgewählt."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# Stunde} other {# Stunden}}"
|
msgstr "{hours, plural, one {# Stunde} other {# Stunden}}"
|
||||||
@@ -31,6 +37,11 @@ msgstr "{hours, plural, one {# Stunde} other {# Stunden}}"
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 Stunde"
|
msgstr "1 Stunde"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr "1 Min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 Woche"
|
msgstr "1 Woche"
|
||||||
@@ -39,6 +50,11 @@ msgstr "1 Woche"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 Stunden"
|
msgstr "12 Stunden"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr "15 Min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 Stunden"
|
msgstr "24 Stunden"
|
||||||
@@ -47,12 +63,22 @@ msgstr "24 Stunden"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 Tage"
|
msgstr "30 Tage"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr "5 Min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Aktionen"
|
msgstr "Aktionen"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Aktiv"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktive Warnungen"
|
msgstr "Aktive Warnungen"
|
||||||
@@ -82,10 +108,16 @@ msgstr "Anzeigeoptionen für Diagramme anpassen."
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Agent"
|
msgstr "Agent"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr "Alarm-Verlauf"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -96,10 +128,14 @@ msgstr "Warnungen"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "Alle Systeme"
|
msgstr "Alle Systeme"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "Möchtest du {name} wirklich löschen?"
|
msgstr "Möchtest du {name} wirklich löschen?"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr "Bist du sicher?"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "Automatisches Kopieren erfordert einen sicheren Kontext."
|
msgstr "Automatisches Kopieren erfordert einen sicheren Kontext."
|
||||||
@@ -152,11 +188,22 @@ msgstr "Beszel verwendet <0>Shoutrrr</0>, um sich mit beliebten Benachrichtigung
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binär"
|
msgstr "Binär"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Cache / Puffer"
|
msgstr "Cache / Puffer"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Abbrechen"
|
msgstr "Abbrechen"
|
||||||
|
|
||||||
@@ -164,6 +211,14 @@ msgstr "Abbrechen"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Vorsicht - potenzieller Datenverlust"
|
msgstr "Vorsicht - potenzieller Datenverlust"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr "Anzeigeeinheiten der Werte ändern."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "Allgemeine Anwendungsoptionen ändern."
|
msgstr "Allgemeine Anwendungsoptionen ändern."
|
||||||
@@ -202,7 +257,12 @@ msgstr "Konfiguriere, wie du Warnbenachrichtigungen erhältst."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Passwort bestätigen"
|
msgstr "Passwort bestätigen"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr "Verbindung unterbrochen"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Fortfahren"
|
msgstr "Fortfahren"
|
||||||
|
|
||||||
@@ -227,7 +287,7 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Umgebungsvariablen kopieren"
|
msgstr "Umgebungsvariablen kopieren"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Host kopieren"
|
msgstr "Host kopieren"
|
||||||
|
|
||||||
@@ -236,6 +296,10 @@ msgstr "Host kopieren"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Linux-Befehl kopieren"
|
msgstr "Linux-Befehl kopieren"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Name kopieren"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Text kopieren"
|
msgstr "Text kopieren"
|
||||||
@@ -252,13 +316,13 @@ msgstr "Kopieren Sie den<0>docker-compose.yml</0> Inhalt für den Agent unten od
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "YAML kopieren"
|
msgstr "YAML kopieren"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "CPU-Auslastung"
|
msgstr "CPU-Auslastung"
|
||||||
|
|
||||||
@@ -266,6 +330,15 @@ msgstr "CPU-Auslastung"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Konto erstellen"
|
msgstr "Konto erstellen"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "Erstellt"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "Kritisch (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -280,7 +353,8 @@ msgstr "Dashboard"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Standardzeitraum"
|
msgstr "Standardzeitraum"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Löschen"
|
msgstr "Löschen"
|
||||||
|
|
||||||
@@ -288,7 +362,7 @@ msgstr "Löschen"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Fingerabdruck löschen"
|
msgstr "Fingerabdruck löschen"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Festplatte"
|
msgstr "Festplatte"
|
||||||
|
|
||||||
@@ -296,6 +370,10 @@ msgstr "Festplatte"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "Festplatten-I/O"
|
msgstr "Festplatten-I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr "Festplatteneinheit"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -324,13 +402,18 @@ msgstr "Dokumentation"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
msgstr "Offline"
|
msgstr "Offline"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "Dauer"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Bearbeiten"
|
msgstr "Bearbeiten"
|
||||||
|
|
||||||
@@ -354,6 +437,7 @@ msgstr "E-Mail-Adresse eingeben..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Fehler"
|
msgstr "Fehler"
|
||||||
@@ -369,6 +453,10 @@ msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {#
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Bestehende Systeme, die nicht in der <0>config.yml</0> definiert sind, werden gelöscht. Bitte mache regelmäßige Backups."
|
msgstr "Bestehende Systeme, die nicht in der <0>config.yml</0> definiert sind, werden gelöscht. Bitte mache regelmäßige Backups."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr "Exportieren"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "Konfiguration exportieren"
|
msgstr "Konfiguration exportieren"
|
||||||
@@ -377,6 +465,10 @@ msgstr "Konfiguration exportieren"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "Exportiere die aktuelle Systemkonfiguration."
|
msgstr "Exportiere die aktuelle Systemkonfiguration."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Authentifizierung fehlgeschlagen"
|
msgstr "Authentifizierung fehlgeschlagen"
|
||||||
@@ -396,12 +488,13 @@ msgstr "Warnung konnte nicht aktualisiert werden"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filter..."
|
msgstr "Filter..."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr ""
|
msgstr "Fingerabdruck"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx
|
#: src/components/alerts/alerts-system.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -448,16 +541,6 @@ msgstr "Ungültige E-Mail-Adresse."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Kernel"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
@@ -471,13 +554,26 @@ msgstr "Anordnung"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Hell"
|
msgstr "Hell"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr "Durchschnittliche Systemlast"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr "Durchschnittliche Systemlast 15 Min"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr "Durchschnittliche Systemlast 1 Min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr "Durchschnittliche Systemlast 5 Min"
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr "Durchschnittliche Last"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
@@ -514,7 +610,7 @@ msgstr "Anleitung zur manuellen Einrichtung"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Max 1 Min"
|
msgstr "Max 1 Min"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Arbeitsspeicher"
|
msgstr "Arbeitsspeicher"
|
||||||
|
|
||||||
@@ -527,11 +623,12 @@ msgstr "Arbeitsspeichernutzung"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Arbeitsspeichernutzung der Docker-Container"
|
msgstr "Arbeitsspeichernutzung der Docker-Container"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Netz"
|
msgstr "Netz"
|
||||||
|
|
||||||
@@ -543,10 +640,19 @@ msgstr "Netzwerkverkehr der Docker-Container"
|
|||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "Netzwerkverkehr der öffentlichen Schnittstellen"
|
msgstr "Netzwerkverkehr der öffentlichen Schnittstellen"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr "Netzwerkeinheit"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Keine Ergebnisse gefunden."
|
msgstr "Keine Ergebnisse gefunden."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr "Keine Ergebnisse."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -566,7 +672,7 @@ msgstr "OAuth 2 / OIDC-Unterstützung"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "Bei jedem Neustart werden die Systeme in der Datenbank aktualisiert, um den in der Datei definierten Systemen zu entsprechen."
|
msgstr "Bei jedem Neustart werden die Systeme in der Datenbank aktualisiert, um den in der Datei definierten Systemen zu entsprechen."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -584,6 +690,12 @@ msgstr "Bestehende Warnungen überschreiben"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Seite"
|
msgstr "Seite"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr "Seite {0} von {1}"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "Seiten / Einstellungen"
|
msgstr "Seiten / Einstellungen"
|
||||||
@@ -605,11 +717,11 @@ msgstr "Das Passwort muss weniger als 72 Bytes lang sein."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
|
msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Pause"
|
msgstr "Pause"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "Pausiert"
|
msgstr "Pausiert"
|
||||||
|
|
||||||
@@ -665,13 +777,12 @@ msgid "Public Key"
|
|||||||
msgstr "Schlüssel"
|
msgstr "Schlüssel"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "Lesen"
|
msgstr "Lesen"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Empfangen"
|
msgstr "Empfangen"
|
||||||
|
|
||||||
@@ -679,7 +790,13 @@ msgstr "Empfangen"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Passwort zurücksetzen"
|
msgstr "Passwort zurücksetzen"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr "Gelöst"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Fortsetzen"
|
msgstr "Fortsetzen"
|
||||||
|
|
||||||
@@ -687,6 +804,10 @@ msgstr "Fortsetzen"
|
|||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "Token rotieren"
|
msgstr "Token rotieren"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr "Zeilen pro Seite"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
|
msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
|
||||||
@@ -698,7 +819,7 @@ msgstr "Einstellungen speichern"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Save system"
|
msgid "Save system"
|
||||||
msgstr ""
|
msgstr "System sichern"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -712,11 +833,14 @@ msgstr "Nach Systemen oder Einstellungen suchen..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du Warnungen erhältst."
|
msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du Warnungen erhältst."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Gesendet"
|
msgstr "Gesendet"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "Prozentuale Schwellenwerte für Zählerfarben festlegen."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "Legt den Standardzeitraum für Diagramme fest, wenn ein System angezeigt wird."
|
msgstr "Legt den Standardzeitraum für Diagramme fest, wenn ein System angezeigt wird."
|
||||||
@@ -744,6 +868,11 @@ msgstr "SMTP-Einstellungen"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Sortieren nach"
|
msgstr "Sortieren nach"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr "Status"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Status"
|
msgstr "Status"
|
||||||
@@ -759,11 +888,16 @@ msgstr "Swap-Nutzung"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "System"
|
msgstr "System"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr "Systemlastdurchschnitt im Zeitverlauf"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Systeme"
|
msgstr "Systeme"
|
||||||
@@ -777,7 +911,7 @@ msgid "Table"
|
|||||||
msgstr "Tabelle"
|
msgstr "Tabelle"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Temperatur"
|
msgstr "Temperatur"
|
||||||
|
|
||||||
@@ -786,6 +920,10 @@ msgstr "Temperatur"
|
|||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "Temperatur"
|
msgstr "Temperatur"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr "Temperatureinheit"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "Temperaturen der Systemsensoren"
|
msgstr "Temperaturen der Systemsensoren"
|
||||||
@@ -802,10 +940,14 @@ msgstr "Testbenachrichtigung gesendet"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in der Benutzertabelle zurück."
|
msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in der Benutzertabelle zurück."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle aktuellen Datensätze für {name} dauerhaft aus der Datenbank gelöscht."
|
msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle aktuellen Datensätze für {name} dauerhaft aus der Datenbank gelöscht."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr "Dadurch werden alle ausgewählten Datensätze dauerhaft aus der Datenbank gelöscht."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Durchsatz von {extraFsName}"
|
msgstr "Durchsatz von {extraFsName}"
|
||||||
@@ -846,13 +988,17 @@ msgstr "Tokens ermöglichen es Agents, sich zu verbinden und zu registrieren. Fi
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren."
|
msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr "Löst aus, wenn der Lastdurchschnitt der letzten Minute einen Schwellenwert überschreitet"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Löst aus, wenn der Lastdurchschnitt der letzten 15 Minuten einen Schwellenwert überschreitet"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 5 minute load average exceeds a threshold"
|
msgid "Triggers when 5 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Löst aus, wenn der Lastdurchschnitt der letzten 5 Minuten einen Schwellenwert überschreitet"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
msgid "Triggers when any sensor exceeds a threshold"
|
||||||
@@ -878,15 +1024,20 @@ msgstr "Löst aus, wenn der Status zwischen online und offline wechselt"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
|
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr "Einheiten"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr "Universeller Token"
|
msgstr "Universeller Token"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr ""
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Updated in real time. Click on a system to view information."
|
msgid "Updated in real time. Click on a system to view information."
|
||||||
@@ -898,7 +1049,8 @@ msgstr "Betriebszeit"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Nutzung"
|
msgstr "Nutzung"
|
||||||
|
|
||||||
@@ -908,7 +1060,6 @@ msgstr "Nutzung der Root-Partition"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "Verwendet"
|
msgstr "Verwendet"
|
||||||
|
|
||||||
@@ -917,10 +1068,18 @@ msgstr "Verwendet"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "Wert"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Ansicht"
|
msgstr "Ansicht"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr "Sieh dir die neusten 200 Alarme an."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "Sichtbare Spalten"
|
msgstr "Sichtbare Spalten"
|
||||||
@@ -933,6 +1092,14 @@ msgstr "Warten auf genügend Datensätze zur Anzeige"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Möchtest du uns helfen, unsere Übersetzungen noch besser zu machen? Schau dir <0>Crowdin</0> für weitere Details an."
|
msgstr "Möchtest du uns helfen, unsere Übersetzungen noch besser zu machen? Schau dir <0>Crowdin</0> für weitere Details an."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "Warnung (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "Warnschwellen"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "Webhook / Push-Benachrichtigungen"
|
msgstr "Webhook / Push-Benachrichtigungen"
|
||||||
@@ -948,8 +1115,8 @@ msgid "Windows command"
|
|||||||
msgstr "Windows-Befehl"
|
msgstr "Windows-Befehl"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "Schreiben"
|
msgstr "Schreiben"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# day} other {# days}}"
|
msgstr "{0, plural, one {# day} other {# days}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr "{0} of {1} row(s) selected."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# hour} other {# hours}}"
|
msgstr "{hours, plural, one {# hour} other {# hours}}"
|
||||||
@@ -26,6 +32,11 @@ msgstr "{hours, plural, one {# hour} other {# hours}}"
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 hour"
|
msgstr "1 hour"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 week"
|
msgstr "1 week"
|
||||||
@@ -34,6 +45,11 @@ msgstr "1 week"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 hours"
|
msgstr "12 hours"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 hours"
|
msgstr "24 hours"
|
||||||
@@ -42,12 +58,22 @@ msgstr "24 hours"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 days"
|
msgstr "30 days"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Actions"
|
msgstr "Actions"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Active"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Active Alerts"
|
msgstr "Active Alerts"
|
||||||
@@ -77,10 +103,16 @@ msgstr "Adjust display options for charts."
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Agent"
|
msgstr "Agent"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr "Alert History"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -91,10 +123,14 @@ msgstr "Alerts"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "All Systems"
|
msgstr "All Systems"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "Are you sure you want to delete {name}?"
|
msgstr "Are you sure you want to delete {name}?"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr "Are you sure?"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "Automatic copy requires a secure context."
|
msgstr "Automatic copy requires a secure context."
|
||||||
@@ -147,11 +183,22 @@ msgstr "Beszel uses <0>Shoutrrr</0> to integrate with popular notification servi
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binary"
|
msgstr "Binary"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Cache / Buffers"
|
msgstr "Cache / Buffers"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
@@ -159,6 +206,14 @@ msgstr "Cancel"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Caution - potential data loss"
|
msgstr "Caution - potential data loss"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr "Change display units for metrics."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "Change general application options."
|
msgstr "Change general application options."
|
||||||
@@ -197,7 +252,12 @@ msgstr "Configure how you receive alert notifications."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Confirm password"
|
msgstr "Confirm password"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr "Connection is down"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Continue"
|
msgstr "Continue"
|
||||||
|
|
||||||
@@ -222,7 +282,7 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Copy env"
|
msgstr "Copy env"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Copy host"
|
msgstr "Copy host"
|
||||||
|
|
||||||
@@ -231,6 +291,10 @@ msgstr "Copy host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copy Linux command"
|
msgstr "Copy Linux command"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copy name"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copy text"
|
msgstr "Copy text"
|
||||||
@@ -247,13 +311,13 @@ msgstr "Copy the<0>docker-compose.yml</0> content for the agent below, or regist
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "Copy YAML"
|
msgstr "Copy YAML"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "CPU Usage"
|
msgstr "CPU Usage"
|
||||||
|
|
||||||
@@ -261,6 +325,15 @@ msgstr "CPU Usage"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Create account"
|
msgstr "Create account"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "Created"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "Critical (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -275,7 +348,8 @@ msgstr "Dashboard"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Default time period"
|
msgstr "Default time period"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Delete"
|
msgstr "Delete"
|
||||||
|
|
||||||
@@ -283,7 +357,7 @@ msgstr "Delete"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Delete fingerprint"
|
msgstr "Delete fingerprint"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Disk"
|
msgstr "Disk"
|
||||||
|
|
||||||
@@ -291,6 +365,10 @@ msgstr "Disk"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "Disk I/O"
|
msgstr "Disk I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr "Disk unit"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -319,13 +397,18 @@ msgstr "Documentation"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
msgstr "Down"
|
msgstr "Down"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "Duration"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
|
|
||||||
@@ -349,6 +432,7 @@ msgstr "Enter email address..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Error"
|
msgstr "Error"
|
||||||
@@ -364,6 +448,10 @@ msgstr "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgstr "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr "Export"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "Export configuration"
|
msgstr "Export configuration"
|
||||||
@@ -372,6 +460,10 @@ msgstr "Export configuration"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "Export your current systems configuration."
|
msgstr "Export your current systems configuration."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Failed to authenticate"
|
msgstr "Failed to authenticate"
|
||||||
@@ -391,6 +483,7 @@ msgstr "Failed to update alert"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filter..."
|
msgstr "Filter..."
|
||||||
|
|
||||||
@@ -443,16 +536,6 @@ msgstr "Invalid email address."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Kernel"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr "L15"
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr "L5"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Language"
|
msgstr "Language"
|
||||||
@@ -466,14 +549,27 @@ msgstr "Layout"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Light"
|
msgstr "Light"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr "Load Average"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr "Load Average 15m"
|
msgstr "Load Average 15m"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr "Load Average 1m"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr "Load Average 5m"
|
msgstr "Load Average 5m"
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr "Load Avg"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr "Log Out"
|
msgstr "Log Out"
|
||||||
@@ -509,7 +605,7 @@ msgstr "Manual setup instructions"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Max 1 min"
|
msgstr "Max 1 min"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Memory"
|
msgstr "Memory"
|
||||||
|
|
||||||
@@ -522,11 +618,12 @@ msgstr "Memory Usage"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Memory usage of docker containers"
|
msgstr "Memory usage of docker containers"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Net"
|
msgstr "Net"
|
||||||
|
|
||||||
@@ -538,10 +635,19 @@ msgstr "Network traffic of docker containers"
|
|||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "Network traffic of public interfaces"
|
msgstr "Network traffic of public interfaces"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr "Network unit"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "No results found."
|
msgstr "No results found."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr "No results."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -561,7 +667,7 @@ msgstr "OAuth 2 / OIDC support"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgstr "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -579,6 +685,12 @@ msgstr "Overwrite existing alerts"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Page"
|
msgstr "Page"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr "Page {0} of {1}"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "Pages / Settings"
|
msgstr "Pages / Settings"
|
||||||
@@ -600,11 +712,11 @@ msgstr "Password must be less than 72 bytes."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Password reset request received"
|
msgstr "Password reset request received"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Pause"
|
msgstr "Pause"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "Paused"
|
msgstr "Paused"
|
||||||
|
|
||||||
@@ -660,13 +772,12 @@ msgid "Public Key"
|
|||||||
msgstr "Public Key"
|
msgstr "Public Key"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "Read"
|
msgstr "Read"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Received"
|
msgstr "Received"
|
||||||
|
|
||||||
@@ -674,7 +785,13 @@ msgstr "Received"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Reset Password"
|
msgstr "Reset Password"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr "Resolved"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Resume"
|
msgstr "Resume"
|
||||||
|
|
||||||
@@ -682,6 +799,10 @@ msgstr "Resume"
|
|||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "Rotate token"
|
msgstr "Rotate token"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr "Rows per page"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
@@ -707,11 +828,14 @@ msgstr "Search for systems or settings..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "See <0>notification settings</0> to configure how you receive alerts."
|
msgstr "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Sent"
|
msgstr "Sent"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "Set percentage thresholds for meter colors."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "Sets the default time range for charts when a system is viewed."
|
msgstr "Sets the default time range for charts when a system is viewed."
|
||||||
@@ -739,6 +863,11 @@ msgstr "SMTP settings"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Sort By"
|
msgstr "Sort By"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr "State"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Status"
|
msgstr "Status"
|
||||||
@@ -754,11 +883,16 @@ msgstr "Swap Usage"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "System"
|
msgstr "System"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr "System load averages over time"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Systems"
|
msgstr "Systems"
|
||||||
@@ -772,7 +906,7 @@ msgid "Table"
|
|||||||
msgstr "Table"
|
msgstr "Table"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Temp"
|
msgstr "Temp"
|
||||||
|
|
||||||
@@ -781,6 +915,10 @@ msgstr "Temp"
|
|||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "Temperature"
|
msgstr "Temperature"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr "Temperature unit"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "Temperatures of system sensors"
|
msgstr "Temperatures of system sensors"
|
||||||
@@ -797,10 +935,14 @@ msgstr "Test notification sent"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "Then log into the backend and reset your user account password in the users table."
|
msgstr "Then log into the backend and reset your user account password in the users table."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgstr "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr "This will permanently delete all selected records from the database."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Throughput of {extraFsName}"
|
msgstr "Throughput of {extraFsName}"
|
||||||
@@ -841,6 +983,10 @@ msgstr "Tokens allow agents to connect and register. Fingerprints are stable ide
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr "Triggers when 15 minute load average exceeds a threshold"
|
msgstr "Triggers when 15 minute load average exceeds a threshold"
|
||||||
@@ -873,12 +1019,17 @@ msgstr "Triggers when status switches between up and down"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Triggers when usage of any disk exceeds a threshold"
|
msgstr "Triggers when usage of any disk exceeds a threshold"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr "Unit preferences"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr "Universal token"
|
msgstr "Universal token"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr "Up"
|
msgstr "Up"
|
||||||
@@ -893,7 +1044,8 @@ msgstr "Uptime"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Usage"
|
msgstr "Usage"
|
||||||
|
|
||||||
@@ -903,7 +1055,6 @@ msgstr "Usage of root partition"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "Used"
|
msgstr "Used"
|
||||||
|
|
||||||
@@ -912,10 +1063,18 @@ msgstr "Used"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Users"
|
msgstr "Users"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "Value"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "View"
|
msgstr "View"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr "View your 200 most recent alerts."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "Visible Fields"
|
msgstr "Visible Fields"
|
||||||
@@ -928,6 +1087,14 @@ msgstr "Waiting for enough records to display"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgstr "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "Warning (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "Warning thresholds"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "Webhook / Push notifications"
|
msgstr "Webhook / Push notifications"
|
||||||
@@ -943,8 +1110,8 @@ msgid "Windows command"
|
|||||||
msgstr "Windows command"
|
msgstr "Windows command"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "Write"
|
msgstr "Write"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: es\n"
|
"Language: es\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-03-06 07:27\n"
|
"PO-Revision-Date: 2025-07-25 22:44\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Spanish\n"
|
"Language-Team: Spanish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -23,6 +23,12 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# día} other {# días}}"
|
msgstr "{0, plural, one {# día} other {# días}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "{0} of {1} row(s) selected."
|
||||||
|
msgstr "{0} de {1} fila(s) seleccionada(s)."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{hours, plural, one {# hora} other {# horas}}"
|
msgstr "{hours, plural, one {# hora} other {# horas}}"
|
||||||
@@ -31,6 +37,11 @@ msgstr "{hours, plural, one {# hora} other {# horas}}"
|
|||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 hora"
|
msgstr "1 hora"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "1 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 semana"
|
msgstr "1 semana"
|
||||||
@@ -39,6 +50,11 @@ msgstr "1 semana"
|
|||||||
msgid "12 hours"
|
msgid "12 hours"
|
||||||
msgstr "12 horas"
|
msgstr "12 horas"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "15 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
msgstr "24 horas"
|
msgstr "24 horas"
|
||||||
@@ -47,12 +63,22 @@ msgstr "24 horas"
|
|||||||
msgid "30 days"
|
msgid "30 days"
|
||||||
msgstr "30 días"
|
msgstr "30 días"
|
||||||
|
|
||||||
|
#. Load average
|
||||||
|
#: src/components/charts/load-average-chart.tsx
|
||||||
|
msgid "5 min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Acciones"
|
msgstr "Acciones"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Activo"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Alertas Activas"
|
msgstr "Alertas Activas"
|
||||||
@@ -82,10 +108,16 @@ msgstr "Ajustar las opciones de visualización para los gráficos."
|
|||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Administrador"
|
msgstr "Administrador"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Agente"
|
msgstr "Agente"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/settings/layout.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Alert History"
|
||||||
|
msgstr "Historial de Alertas"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
@@ -96,10 +128,14 @@ msgstr "Alertas"
|
|||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "Todos los Sistemas"
|
msgstr "Todos los Sistemas"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
msgstr "¿Está seguro de que desea eliminar {name}?"
|
msgstr "¿Está seguro de que desea eliminar {name}?"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Are you sure?"
|
||||||
|
msgstr "¿Estás seguro?"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
msgstr "La copia automática requiere un contexto seguro."
|
msgstr "La copia automática requiere un contexto seguro."
|
||||||
@@ -152,11 +188,22 @@ msgstr "Beszel utiliza <0>Shoutrrr</0> para integrarse con servicios populares d
|
|||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binario"
|
msgstr "Binario"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Caché / Buffers"
|
msgstr "Caché / Buffers"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancelar"
|
msgstr "Cancelar"
|
||||||
|
|
||||||
@@ -164,6 +211,14 @@ msgstr "Cancelar"
|
|||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Precaución - posible pérdida de datos"
|
msgstr "Precaución - posible pérdida de datos"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Celsius (°C)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Change display units for metrics."
|
||||||
|
msgstr "Cambiar las unidades de visualización de las métricas."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
msgstr "Cambiar las opciones generales de la aplicación."
|
msgstr "Cambiar las opciones generales de la aplicación."
|
||||||
@@ -202,7 +257,12 @@ msgstr "Configure cómo recibe las notificaciones de alertas."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Confirmar contraseña"
|
msgstr "Confirmar contraseña"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
msgid "Connection is down"
|
||||||
|
msgstr "La conexión está caída"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Continuar"
|
msgstr "Continuar"
|
||||||
|
|
||||||
@@ -227,7 +287,7 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Copiar env"
|
msgstr "Copiar env"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Copiar host"
|
msgstr "Copiar host"
|
||||||
|
|
||||||
@@ -236,6 +296,10 @@ msgstr "Copiar host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copiar comando de Linux"
|
msgstr "Copiar comando de Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copiar nombre"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copiar texto"
|
msgstr "Copiar texto"
|
||||||
@@ -252,13 +316,13 @@ msgstr "Copia el contenido del<0>docker-compose.yml</0> para el agente a continu
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "Copiar YAML"
|
msgstr "Copiar YAML"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "Uso de CPU"
|
msgstr "Uso de CPU"
|
||||||
|
|
||||||
@@ -266,6 +330,15 @@ msgstr "Uso de CPU"
|
|||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Crear cuenta"
|
msgstr "Crear cuenta"
|
||||||
|
|
||||||
|
#. Context: date created
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "Creado"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Critical (%)"
|
||||||
|
msgstr "Crítico (%)"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
@@ -280,7 +353,8 @@ msgstr "Tablero"
|
|||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Período de tiempo predeterminado"
|
msgstr "Período de tiempo predeterminado"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Eliminar"
|
msgstr "Eliminar"
|
||||||
|
|
||||||
@@ -288,7 +362,7 @@ msgstr "Eliminar"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Eliminar huella digital"
|
msgstr "Eliminar huella digital"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Disco"
|
msgstr "Disco"
|
||||||
|
|
||||||
@@ -296,6 +370,10 @@ msgstr "Disco"
|
|||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "E/S de Disco"
|
msgstr "E/S de Disco"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Disk unit"
|
||||||
|
msgstr "Unidad de disco"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
@@ -324,13 +402,18 @@ msgstr "Documentación"
|
|||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Down"
|
msgid "Down"
|
||||||
msgstr "Abajo"
|
msgstr "Abajo"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "Duración"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Editar"
|
msgstr "Editar"
|
||||||
|
|
||||||
@@ -354,6 +437,7 @@ msgstr "Ingrese dirección de correo..."
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Error"
|
msgstr "Error"
|
||||||
@@ -369,6 +453,10 @@ msgstr "Excede {0}{1} en el último {2, plural, one {# minuto} other {# minutos}
|
|||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Los sistemas existentes no definidos en <0>config.yml</0> serán eliminados. Por favor, haga copias de seguridad regularmente."
|
msgstr "Los sistemas existentes no definidos en <0>config.yml</0> serán eliminados. Por favor, haga copias de seguridad regularmente."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Export"
|
||||||
|
msgstr "Exportar"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
msgstr "Exportar configuración"
|
msgstr "Exportar configuración"
|
||||||
@@ -377,6 +465,10 @@ msgstr "Exportar configuración"
|
|||||||
msgid "Export your current systems configuration."
|
msgid "Export your current systems configuration."
|
||||||
msgstr "Exporte la configuración actual de sus sistemas."
|
msgstr "Exporte la configuración actual de sus sistemas."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Fahrenheit (°F)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Error al autenticar"
|
msgstr "Error al autenticar"
|
||||||
@@ -396,12 +488,13 @@ msgstr "Error al actualizar la alerta"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filtrar..."
|
msgstr "Filtrar..."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr ""
|
msgstr "Huella dactilar"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx
|
#: src/components/alerts/alerts-system.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -448,16 +541,6 @@ msgstr "Dirección de correo electrónico no válida."
|
|||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Kernel"
|
||||||
|
|
||||||
#. Load average 15 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L15"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Load average 5 minutes
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
|
||||||
msgid "L5"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Idioma"
|
msgstr "Idioma"
|
||||||
@@ -471,13 +554,26 @@ msgstr "Diseño"
|
|||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Claro"
|
msgstr "Claro"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "Load Average"
|
||||||
|
msgstr "Carga Media"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr "Carga media 15m"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Load Average 1m"
|
||||||
|
msgstr "Carga media 1m"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr "Carga media 5m"
|
||||||
|
|
||||||
|
#. Short label for load average
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Load Avg"
|
||||||
|
msgstr "Carga media"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
@@ -514,7 +610,7 @@ msgstr "Instrucciones manuales de configuración"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Máx 1 min"
|
msgstr "Máx 1 min"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Memoria"
|
msgstr "Memoria"
|
||||||
|
|
||||||
@@ -527,11 +623,12 @@ msgstr "Uso de Memoria"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Uso de memoria de los contenedores de Docker"
|
msgstr "Uso de memoria de los contenedores de Docker"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Nombre"
|
msgstr "Nombre"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Red"
|
msgstr "Red"
|
||||||
|
|
||||||
@@ -543,10 +640,19 @@ msgstr "Tráfico de red de los contenedores de Docker"
|
|||||||
msgid "Network traffic of public interfaces"
|
msgid "Network traffic of public interfaces"
|
||||||
msgstr "Tráfico de red de interfaces públicas"
|
msgstr "Tráfico de red de interfaces públicas"
|
||||||
|
|
||||||
|
#. Context: Bytes or bits
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Network unit"
|
||||||
|
msgstr "Unidad de red"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "No se encontraron resultados."
|
msgstr "No se encontraron resultados."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "No results."
|
||||||
|
msgstr "Sin resultados."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -566,7 +672,7 @@ msgstr "Soporte para OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "En cada reinicio, los sistemas en la base de datos se actualizarán para coincidir con los sistemas definidos en el archivo."
|
msgstr "En cada reinicio, los sistemas en la base de datos se actualizarán para coincidir con los sistemas definidos en el archivo."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
@@ -584,6 +690,12 @@ msgstr "Sobrescribir alertas existentes"
|
|||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Página"
|
msgstr "Página"
|
||||||
|
|
||||||
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
|
#. placeholder {1}: table.getPageCount()
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Page {0} of {1}"
|
||||||
|
msgstr "Página {0} de {1}"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
msgstr "Páginas / Configuraciones"
|
msgstr "Páginas / Configuraciones"
|
||||||
@@ -605,11 +717,11 @@ msgstr "La contraseña debe ser menor de 72 bytes."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Solicitud de restablecimiento de contraseña recibida"
|
msgstr "Solicitud de restablecimiento de contraseña recibida"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Pausar"
|
msgstr "Pausar"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
msgstr "Pausado"
|
msgstr "Pausado"
|
||||||
|
|
||||||
@@ -665,13 +777,12 @@ msgid "Public Key"
|
|||||||
msgstr "Clave Pública"
|
msgstr "Clave Pública"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Read"
|
msgid "Read"
|
||||||
msgstr "Lectura"
|
msgstr "Lectura"
|
||||||
|
|
||||||
#. Network bytes received (download)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Recibido"
|
msgstr "Recibido"
|
||||||
|
|
||||||
@@ -679,7 +790,13 @@ msgstr "Recibido"
|
|||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Restablecer Contraseña"
|
msgstr "Restablecer Contraseña"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Resolved"
|
||||||
|
msgstr "Resuelto"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Reanudar"
|
msgstr "Reanudar"
|
||||||
|
|
||||||
@@ -687,6 +804,10 @@ msgstr "Reanudar"
|
|||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "Rotar token"
|
msgstr "Rotar token"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "Rows per page"
|
||||||
|
msgstr "Filas por página"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
|
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
|
||||||
@@ -712,11 +833,14 @@ msgstr "Buscar sistemas o configuraciones..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Consulte <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
|
msgstr "Consulte <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
|
||||||
|
|
||||||
#. Network bytes sent (upload)
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Enviado"
|
msgstr "Enviado"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Set percentage thresholds for meter colors."
|
||||||
|
msgstr "Establecer umbrales de porcentaje para los colores de los medidores."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
msgid "Sets the default time range for charts when a system is viewed."
|
||||||
msgstr "Establece el rango de tiempo predeterminado para los gráficos cuando se visualiza un sistema."
|
msgstr "Establece el rango de tiempo predeterminado para los gráficos cuando se visualiza un sistema."
|
||||||
@@ -744,6 +868,11 @@ msgstr "Configuración SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Ordenar por"
|
msgstr "Ordenar por"
|
||||||
|
|
||||||
|
#. Context: alert state (active or resolved)
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "State"
|
||||||
|
msgstr "Estado"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Estado"
|
msgstr "Estado"
|
||||||
@@ -759,11 +888,16 @@ msgstr "Uso de Swap"
|
|||||||
#. System theme
|
#. System theme
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr "Sistema"
|
msgstr "Sistema"
|
||||||
|
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
|
msgid "System load averages over time"
|
||||||
|
msgstr "Promedios de carga del sistema a lo largo del tiempo"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Sistemas"
|
msgstr "Sistemas"
|
||||||
@@ -777,7 +911,7 @@ msgid "Table"
|
|||||||
msgstr "Tabla"
|
msgstr "Tabla"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Temperatura"
|
msgstr "Temperatura"
|
||||||
|
|
||||||
@@ -786,6 +920,10 @@ msgstr "Temperatura"
|
|||||||
msgid "Temperature"
|
msgid "Temperature"
|
||||||
msgstr "Temperatura"
|
msgstr "Temperatura"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Temperature unit"
|
||||||
|
msgstr "Unidad de temperatura"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
msgstr "Temperaturas de los sensores del sistema"
|
msgstr "Temperaturas de los sensores del sistema"
|
||||||
@@ -802,10 +940,14 @@ msgstr "Notificación de prueba enviada"
|
|||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
|
msgstr "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos los registros actuales de {name} de la base de datos."
|
msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos los registros actuales de {name} de la base de datos."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "This will permanently delete all selected records from the database."
|
||||||
|
msgstr "Esto eliminará permanentemente todos los registros seleccionados de la base de datos."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Rendimiento de {extraFsName}"
|
msgstr "Rendimiento de {extraFsName}"
|
||||||
@@ -830,7 +972,7 @@ msgstr "Alternar tema"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr "Token"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
@@ -846,13 +988,17 @@ msgstr "Los tokens permiten que los agentes se conecten y registren. Las huellas
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub."
|
msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
|
msgstr "Se activa cuando la carga media de 1 minuto supera un umbral"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Se activa cuando la carga media de 15 minutos supera un umbral"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when 5 minute load average exceeds a threshold"
|
msgid "Triggers when 5 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Se activa cuando la carga media de 5 minutos supera un umbral"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
msgid "Triggers when any sensor exceeds a threshold"
|
||||||
@@ -878,12 +1024,17 @@ msgstr "Se activa cuando el estado cambia entre activo e inactivo"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Se activa cuando el uso de cualquier disco supera un umbral"
|
msgstr "Se activa cuando el uso de cualquier disco supera un umbral"
|
||||||
|
|
||||||
|
#. Temperature / network units
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Unit preferences"
|
||||||
|
msgstr "Preferencias de unidad"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr "Token universal"
|
msgstr "Token universal"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Up"
|
msgid "Up"
|
||||||
msgstr "Activo"
|
msgstr "Activo"
|
||||||
@@ -898,7 +1049,8 @@ msgstr "Tiempo de actividad"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Uso"
|
msgstr "Uso"
|
||||||
|
|
||||||
@@ -908,7 +1060,6 @@ msgstr "Uso de la partición raíz"
|
|||||||
|
|
||||||
#: src/components/charts/swap-chart.tsx
|
#: src/components/charts/swap-chart.tsx
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
|
||||||
msgid "Used"
|
msgid "Used"
|
||||||
msgstr "Usado"
|
msgstr "Usado"
|
||||||
|
|
||||||
@@ -917,10 +1068,18 @@ msgstr "Usado"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Usuarios"
|
msgstr "Usuarios"
|
||||||
|
|
||||||
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "Valor"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Vista"
|
msgstr "Vista"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
msgid "View your 200 most recent alerts."
|
||||||
|
msgstr "Ver sus 200 alertas más recientes."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
msgstr "Columnas visibles"
|
msgstr "Columnas visibles"
|
||||||
@@ -933,6 +1092,14 @@ msgstr "Esperando suficientes registros para mostrar"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
|
msgstr "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning (%)"
|
||||||
|
msgstr "Advertencia (%)"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Warning thresholds"
|
||||||
|
msgstr "Umbrales de advertencia"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Webhook / Push notifications"
|
msgid "Webhook / Push notifications"
|
||||||
msgstr "Notificaciones Webhook / Push"
|
msgstr "Notificaciones Webhook / Push"
|
||||||
@@ -948,8 +1115,8 @@ msgid "Windows command"
|
|||||||
msgstr "Comando Windows"
|
msgstr "Comando Windows"
|
||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/charts/area-chart.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Write"
|
msgid "Write"
|
||||||
msgstr "Escritura"
|
msgstr "Escritura"
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user