mirror of
https://github.com/bastienwirtz/homer.git
synced 2026-01-23 02:15:09 +00:00
Compare commits
38 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6367012675 | ||
|
|
d7bee37405 | ||
|
|
4cf69b3a25 | ||
|
|
2e1c7b3d27 | ||
|
|
151c136923 | ||
|
|
2a27bee30e | ||
|
|
d1356c3e6a | ||
|
|
8d82c77630 | ||
|
|
f11c14e764 | ||
|
|
62606e0caf | ||
|
|
184c16d46c | ||
|
|
ee57fa05fb | ||
|
|
8249aa8ae4 | ||
|
|
a4ec46ee35 | ||
|
|
bac62457f1 | ||
|
|
3913c30a56 | ||
|
|
3f49479556 | ||
|
|
6aa29935f6 | ||
|
|
19c5f174e8 | ||
|
|
89a264563a | ||
|
|
d19724b896 | ||
|
|
8a598dbdc0 | ||
|
|
2f4bbee491 | ||
|
|
7bd56d941a | ||
|
|
5a816709e5 | ||
|
|
81c7496264 | ||
|
|
4904717db0 | ||
|
|
92a79ffdfb | ||
|
|
35e49e3d91 | ||
|
|
68fb183c20 | ||
|
|
9054bd8941 | ||
|
|
b821651017 | ||
|
|
5b29bc411c | ||
|
|
06b677ab76 | ||
|
|
843a814ac5 | ||
|
|
1b6c3e6213 | ||
|
|
90ba82de8f | ||
|
|
6f902b78c0 |
18 changed files with 1521 additions and 568 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -23,4 +23,8 @@ yarn-error.log*
|
|||
# App configuration
|
||||
config.yml
|
||||
|
||||
.drone.yml
|
||||
.drone.yml
|
||||
|
||||
# Specific Agent file
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
|
|
|
|||
84
AGENTS.md
Normal file
84
AGENTS.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# AGENTS Instructions
|
||||
|
||||
This file provides guidance to AI Agents when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies (PNPM enforced via packageManager)
|
||||
pnpm dev # Start development server on http://localhost:3000
|
||||
pnpm mock # Start mock API server for testing service integrations
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
pnpm lint # Run ESLint with auto-fix
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Homer is a static Vue.js 3 PWA dashboard that loads configuration from YAML files. The architecture is service-oriented with dynamic component loading.
|
||||
|
||||
### Core Application Structure
|
||||
|
||||
- **Entry Point**: `src/main.js` mounts the Vue app
|
||||
- **Root Component**: `src/App.vue` handles layout, configuration loading, and routing
|
||||
- **Configuration System**: YAML-based with runtime merging of defaults (`src/assets/defaults.yml`) and user config (`/assets/config.yml`)
|
||||
- **Service Components**: 53 specialized integrations in `src/components/services/` that extend a Generic component pattern
|
||||
|
||||
### Service Integration Pattern
|
||||
|
||||
All service components follow this architecture:
|
||||
|
||||
- Extend `Generic.vue` using Vue slots (`<template #indicator>`, `<template #content>`, `<template #icon>`)
|
||||
- Use the `service.js` mixin (`src/mixins/service.js`) for common API functionality
|
||||
- Use a custom `fetch` method provided by the service mixin to seamlessly support proxy configuration, custom headers, and credentials.
|
||||
|
||||
### Configuration & Routing
|
||||
|
||||
- **Multi-page Support**: Hash-based routing without Vue Router
|
||||
- **Dynamic Config Loading**: External URLs supported via `config.remote_config`
|
||||
- **Theme System**: CSS layers architecture with three built-in themes in `src/assets/themes/`
|
||||
- **Asset Management**: Static files served from `/assets/` with runtime configuration merging
|
||||
|
||||
### Build System Details
|
||||
|
||||
- **Vite 7**: Modern build tool with Vue plugin
|
||||
- **PWA**: Auto-updating service worker via `vite-plugin-pwa`
|
||||
- **SCSS**: Bulma framework with modular component styling
|
||||
- **Docker**: Multi-stage build (Node.js → Alpine + Lighttpd)
|
||||
|
||||
### Mock Data Creation Pattern
|
||||
|
||||
When creating mock data for service components testing:
|
||||
|
||||
**Structure**: `dummy-data/[component-name]/[api-path]/[endpoint]`
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Analyze component**: Read the Vue component file to identify API calls (look for `this.fetch()` calls)
|
||||
2. **Check existing mock**: If mock directory exists, read existing files to check for missing fields
|
||||
3. **Create/update structure**: `mkdir -p dummy-data/[lowercase-component-name]/` and mirror API endpoint paths
|
||||
4. **Create/update JSON files**: Write realistic mock responses matching the expected data structure
|
||||
5. **Verify fields**: Ensure all fields used in the component's computed properties and templates are included
|
||||
6. **Update existing mocks**: If mock files exist but are missing fields, add the missing fields without removing existing data
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Component directory name should be lowercase version of component name (e.g., `AdGuardHome.vue` → `adguardhome/`)
|
||||
- Directory structure mirrors API endpoints exactly
|
||||
- Files contain JSON responses (no file extension needed)
|
||||
- Mock server serves from `dummy-data/` via `pnpm mock` command
|
||||
- Each component gets isolated directory to prevent API path conflicts
|
||||
- When updating existing mocks, preserve existing data and only add missing fields required by the component
|
||||
- Always read existing mock files first to understand current structure before making changes
|
||||
|
||||
**Example**: For `AdGuardHome.vue`:
|
||||
- API calls: `/control/status`, `/control/stats`
|
||||
- Mock files: `dummy-data/adguardhome/control/status`, `dummy-data/adguardhome/control/stats`
|
||||
|
||||
### Development Notes
|
||||
|
||||
- Use `pnpm mock` to test service integrations with dummy data
|
||||
- Configuration changes require restart in development mode
|
||||
- New service components should follow the Generic component slot pattern
|
||||
- Themes use CSS custom properties for dynamic color switching
|
||||
- The app has no backend dependencies and generates static files only
|
||||
|
|
@ -7,7 +7,7 @@ First off, thank you for considering contributing to Homer!
|
|||
### Project philosophy
|
||||
|
||||
Homer is meant to be a light and very simple dashboard that keeps all your useful utilities at hand. The few features implemented in Homer focus on
|
||||
UX and usability. If you are looking for a full featured dashboard, there are tons of great stuff out there like https://heimdall.site/, https://github.com/rmountjoy92/DashMachine or https://organizr.app/.
|
||||
UX and usability. If you are looking for a full featured dashboard, there are tons of great stuff out there like https://gethomepage.dev/, https://heimdall.site/, https://github.com/rmountjoy92/DashMachine or https://organizr.app/.
|
||||
|
||||
- Configuration is stored in a simple config file, avoiding the need for a backend/database while making it possible to use versioning or [config template](https://docs.ansible.com/ansible/latest/user_guide/playbooks_templating.html).
|
||||
- Only modern browsers are supported, feel free to use any JS features without any polyfill as soon as the latest version of the major browsers supports them.
|
||||
|
|
@ -33,6 +33,15 @@ For all contributions, please respect the following guidelines:
|
|||
If you want to add a feature, it's often best to talk about it before starting to work on it and submitting a pull request. It's not mandatory at all, but
|
||||
feel free to open an issue to present your idea.
|
||||
|
||||
### Working with AI Agents
|
||||
|
||||
This repository include an [`AGENTS.md`](https://github.com/bastienwirtz/homer/blob/main/AGENTS.md) instruction file for agents. It use an [open format](https://agents.md/), which most agent should natively use for context. However, for specific agent like Claude Code or Gemini, you will have to specifically ask it to read the file or create symlink:
|
||||
|
||||
```sh
|
||||
ln -s AGENTS.md CLAUDE.md
|
||||
ln -s AGENTS.md GEMINI.md
|
||||
```
|
||||
|
||||
### How to submit a contribution
|
||||
|
||||
The general process to submit a contribution is as follow:
|
||||
|
|
|
|||
|
|
@ -183,9 +183,9 @@ Empty values (either in `config.yml` or the endpoint data) will hide the element
|
|||
## Connectivity checks
|
||||
|
||||
As a webapp (PWA) the dashboard can still be displayed when your homer server is offline.
|
||||
The connectivity checker periodically send a HEAD request bypassing the PWA cache to the dashbord page to make sure it's still reachable.
|
||||
The connectivity checker periodically sends a HEAD request bypassing the PWA cache to the dashbord page to make sure it's still reachable.
|
||||
|
||||
It can be useful when you access your dashboard through a VPN or ssh tunnel for example, to know is your conection is up. It also helps when using an authentication proxy, it will reloads the page if the authentication expires (when a redirection is send in response to the HEAD request).
|
||||
It can be useful when you access your dashboard through a VPN or ssh tunnel for example, to know if your conection is up. It also helps when using an authentication proxy, it will reload the page if the authentication expires (when a redirect is send in response to the HEAD request).
|
||||
|
||||
## Style Options
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Available services are located in `src/components/`:
|
|||
- [Docker Socket Proxy](#docker-socket-proxy)
|
||||
- [Emby / Jellyfin](#emby--jellyfin)
|
||||
- [FreshRSS](#freshrss)
|
||||
- [Gatus](#gatus)
|
||||
- [Gitea / Forgejo](#gitea--forgejo)
|
||||
- [Glances](#glances)
|
||||
- [Gotify](#gotify)
|
||||
|
|
@ -31,6 +32,7 @@ Available services are located in `src/components/`:
|
|||
- [Matrix](#matrix)
|
||||
- [Mealie](#mealie)
|
||||
- [Medusa](#medusa)
|
||||
- [Miniflux](#miniflux)
|
||||
- [Nextcloud](#nextcloud)
|
||||
- [OctoPrint / Moonraker](#octoprintmoonraker)
|
||||
- [Olivetin](#olivetin)
|
||||
|
|
@ -53,6 +55,7 @@ Available services are located in `src/components/`:
|
|||
- [Tautulli](#tautulli)
|
||||
- [Tdarr](#tdarr)
|
||||
- [Traefik](#traefik)
|
||||
- [Transmission](#transmission)
|
||||
- [TrueNas Scale](#truenas-scale)
|
||||
- [Uptime Kuma](#uptime-kuma)
|
||||
- [Vaultwarden](#vaultwarden)
|
||||
|
|
@ -162,6 +165,32 @@ Displays unread article count and total subscriptions from your FreshRSS server.
|
|||
password: "<---your-password--->"
|
||||
```
|
||||
|
||||
## Gatus
|
||||
|
||||
The Gatus service displays information about the configured services from the defined Gatus server.
|
||||
Two lines are needed in the config.yml :
|
||||
|
||||
```yaml
|
||||
type: "Gatus"
|
||||
url: "http://192.168.0.151/gatus"
|
||||
|
||||
```
|
||||
|
||||
Optionally, the results can be filtered to only include jobs in the defined groups:
|
||||
```yaml
|
||||
groups: [Services, External]
|
||||
```
|
||||
|
||||
The status can be checked regularly by defining an update Interval in ms:
|
||||
```yaml
|
||||
updateInterval: 5000
|
||||
```
|
||||
|
||||
The average times can be hidden (saves their calculation also) by setting the following:
|
||||
```yaml
|
||||
hideaverages: true
|
||||
```
|
||||
|
||||
## Gitea / Forgejo
|
||||
|
||||
Displays a Gitea / Forgejo version.
|
||||
|
|
@ -361,6 +390,22 @@ The url must be the root url of Medusa application.
|
|||
|
||||
**API Key**: The Medusa API key can be found in General configuration > Interface. It is needed to access Medusa API.
|
||||
|
||||
## Miniflux
|
||||
|
||||
Displays the number of unread articles from your Miniflux RSS reader.
|
||||
|
||||
```yaml
|
||||
- name: "Miniflux"
|
||||
type: "Miniflux"
|
||||
logo: "assets/tools/sample.png"
|
||||
url: https://my-service.url
|
||||
apikey: "<---insert-api-key-here--->"
|
||||
style: "status" # Either "status" or "counter"
|
||||
checkInterval: 60000 # Optional: Interval (in ms) for updating the unread count
|
||||
```
|
||||
|
||||
**API Key**: Generate an API key in Miniflux web interface under **Settings > API Keys > Create a new API key**
|
||||
|
||||
## Nextcloud
|
||||
|
||||
Displays Nextcloud version and shows if Nextcloud is online, offline, or in [maintenance
|
||||
|
|
@ -721,6 +766,23 @@ Displays Traefik.
|
|||
|
||||
**Authentication**: If BasicAuth is set, credentials will be encoded in Base64 and sent as an Authorization header (`Basic <encoded_value>`). The value must be formatted as "admin:password".
|
||||
|
||||
## Transmission
|
||||
|
||||
Displays the global upload and download rates, as well as the number of active torrents from your Transmission daemon.
|
||||
The service communicates with the Transmission RPC interface which needs to be accessible from the browser.
|
||||
|
||||
```yaml
|
||||
- name: "Transmission"
|
||||
logo: "assets/tools/sample.png"
|
||||
url: "http://192.168.1.2:9091" # Your Transmission web interface URL
|
||||
type: "Transmission"
|
||||
auth: "username:password" # Optional: HTTP Basic Auth
|
||||
interval: 5000 # Optional: Interval for refreshing data (ms)
|
||||
target: "_blank" # Optional: HTML a tag target attribute
|
||||
```
|
||||
|
||||
The service automatically handles Transmission's session management and CSRF protection.
|
||||
|
||||
## Truenas Scale
|
||||
|
||||
Displays TrueNAS version.
|
||||
|
|
|
|||
211
dummy-data/gatus/api/v1/endpoints/statuses
Normal file
211
dummy-data/gatus/api/v1/endpoints/statuses
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
[
|
||||
{
|
||||
"name": "Gateway",
|
||||
"group": "Services",
|
||||
"key": "services_gateway",
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": true,
|
||||
"timestamp": "2025-05-26T07:35:41.784208588Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": true,
|
||||
"timestamp": "2025-05-26T07:40:41.804489793Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": true,
|
||||
"timestamp": "2025-05-26T07:45:41.837925713Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": true,
|
||||
"timestamp": "2025-05-26T07:50:41.848391366Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Website",
|
||||
"group": "External",
|
||||
"key": "external_website",
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "www.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": false
|
||||
}
|
||||
],
|
||||
"success": false,
|
||||
"timestamp": "2025-05-26T07:35:41.784208588Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": false
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": false,
|
||||
"timestamp": "2025-05-26T07:40:41.804489793Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": true,
|
||||
"timestamp": "2025-05-26T07:45:41.837925713Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "gateway.example.com",
|
||||
"duration": 10000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"condition": "[RESPONSE_TIME] < 500",
|
||||
"success": true
|
||||
}
|
||||
],
|
||||
"success": true,
|
||||
"timestamp": "2025-05-26T07:50:41.848391366Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DNS",
|
||||
"group": "Services",
|
||||
"key": "services_dns",
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "ns1.example",
|
||||
"duration": 20000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": false
|
||||
}
|
||||
],
|
||||
"success": false,
|
||||
"timestamp": "2025-05-26T07:35:41.784208588Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "ns1.example.com",
|
||||
"duration": 20000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": false
|
||||
}
|
||||
],
|
||||
"success": false,
|
||||
"timestamp": "2025-05-26T07:40:41.804489793Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "ns1.example.com",
|
||||
"duration": 20000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": false
|
||||
}
|
||||
],
|
||||
"success": false,
|
||||
"timestamp": "2025-05-26T07:45:41.837925713Z"
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"hostname": "ns1.example.com",
|
||||
"duration": 20000000,
|
||||
"conditionResults": [
|
||||
{
|
||||
"condition": "[STATUS] == 200",
|
||||
"success": false
|
||||
}
|
||||
],
|
||||
"success": false,
|
||||
"timestamp": "2025-05-26T07:50:41.848391366Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
36
dummy-data/miniflux/v1/entries
Normal file
36
dummy-data/miniflux/v1/entries
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"total": 42,
|
||||
"entries": [
|
||||
{
|
||||
"id": 888,
|
||||
"user_id": 1,
|
||||
"feed_id": 42,
|
||||
"title": "Example Unread Entry",
|
||||
"url": "http://example.org/article.html",
|
||||
"comments_url": "",
|
||||
"author": "John Doe",
|
||||
"content": "<p>This is an unread RSS entry</p>",
|
||||
"hash": "29f99e4074cdacca1766f47697d03c66070ef6a14770a1fd5a867483c207a1bb",
|
||||
"published_at": "2025-11-11T16:15:19Z",
|
||||
"created_at": "2025-11-11T16:15:19Z",
|
||||
"status": "unread",
|
||||
"share_code": "",
|
||||
"starred": false,
|
||||
"reading_time": 5,
|
||||
"enclosures": null,
|
||||
"feed": {
|
||||
"id": 42,
|
||||
"user_id": 1,
|
||||
"title": "Tech Blog",
|
||||
"site_url": "http://example.org",
|
||||
"feed_url": "http://example.org/feed.atom",
|
||||
"checked_at": "2025-11-11T21:06:03.133839Z",
|
||||
"category": {
|
||||
"id": 22,
|
||||
"user_id": 1,
|
||||
"title": "Technology"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -6,7 +6,14 @@ import eslintConfigPrettier from "@vue/eslint-config-prettier";
|
|||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{ files: ["**/*.{js,mjs,cjs,vue}"] },
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__APP_VERSION__: "readable",
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
eslintConfigPrettier,
|
||||
|
|
|
|||
24
package.json
24
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "homer",
|
||||
"version": "25.09.1",
|
||||
"version": "25.11.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -13,24 +13,24 @@
|
|||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"bulma": "^1.0.4",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"vue": "^3.5.21",
|
||||
"yaml": "^2.8.1"
|
||||
"vue": "^3.5.26",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^16.4.0",
|
||||
"globals": "^17.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass-embedded": "^1.93.0",
|
||||
"vite": "^7.1.6",
|
||||
"vite-plugin-pwa": "^1.0.3"
|
||||
"prettier": "^3.8.0",
|
||||
"sass-embedded": "^1.97.2",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.17.0+sha512.fce8a3dd29a4ed2ec566fb53efbb04d8c44a0f05bc6f24a73046910fb9c3ce7afa35a0980500668fa3573345bd644644fa98338fa168235c80f4aa17aa17fbef",
|
||||
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": []
|
||||
}
|
||||
|
|
|
|||
1122
pnpm-lock.yaml
generated
1122
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -54,7 +54,6 @@
|
|||
/>
|
||||
</Navbar>
|
||||
</div>
|
||||
|
||||
<section id="main-section" class="section">
|
||||
<div v-cloak class="container">
|
||||
<ConnectivityChecker
|
||||
|
|
@ -154,6 +153,7 @@ export default {
|
|||
this.buildDashboard();
|
||||
window.onhashchange = this.buildDashboard;
|
||||
this.loaded = true;
|
||||
console.info(`Homer v${__APP_VERSION__}`);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.onhashchange = null;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
--card-shadow: rgba(0, 0, 0, 0.5);
|
||||
--link: #3273dc;
|
||||
--link-hover: #2e4053;
|
||||
--background-image: url("assets/themes/walkxcode/wallpaper-light.webp");
|
||||
--background-image: url("/assets/themes/walkxcode/wallpaper-light.webp");
|
||||
}
|
||||
|
||||
.theme-walkxcode.dark {
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
--card-shadow: rgba(0, 0, 0, 0.5);
|
||||
--link: #ffffff;
|
||||
--link-hover: #fafafa;
|
||||
--background-image: url("assets/themes/walkxcode/wallpaper.webp");
|
||||
--background-image: url("/assets/themes/walkxcode/wallpaper.webp");
|
||||
}
|
||||
|
||||
// theme
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<GroupHeader v-if="group.name" :group="group" class="group-title" />
|
||||
<Service
|
||||
v-for="(item, index) in group.items"
|
||||
:key="`srv-${index}`"
|
||||
:key="`srv-${groupIndex}-${index}-${item.name || item.type}`"
|
||||
:item="item"
|
||||
:proxy="proxy"
|
||||
:class="item.class || group.class"
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
/>
|
||||
<Service
|
||||
v-for="(item, index) in group.items"
|
||||
:key="`srv-${index}`"
|
||||
:key="`srv-${groupIndex}-${index}-${item.name || item.type}`"
|
||||
:item="item"
|
||||
:proxy="proxy"
|
||||
:class="[
|
||||
|
|
|
|||
153
src/components/services/Gatus.vue
Normal file
153
src/components/services/Gatus.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<Generic :item="item">
|
||||
<template #content>
|
||||
<p class="title is-4">{{ item.name }}</p>
|
||||
<p class="subtitle is-6">
|
||||
<template v-if="item.subtitle">
|
||||
{{ item.subtitle }}
|
||||
</template>
|
||||
<i class="fa-solid fa-signal"></i> {{ up }}/{{ total }}
|
||||
<template v-if="avgRespTime > 0">
|
||||
<span class="separator"> | </span>
|
||||
<i class="fa-solid fa-stopwatch"></i> {{ avgRespTime }} ms avg.
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<div v-if="status !== false" class="status" :class="status">
|
||||
{{ percentageGood }}%
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
|
||||
export default {
|
||||
name: "Gatus",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
data: () => ({
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
avgRespTime: NaN,
|
||||
percentageGood: NaN,
|
||||
status: false,
|
||||
statusMessage: false,
|
||||
}),
|
||||
created() {
|
||||
const updateInterval = parseInt(this.item.updateInterval, 10) || 0;
|
||||
if (updateInterval > 0) {
|
||||
setInterval(() => this.fetchStatus(), updateInterval);
|
||||
}
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
this.fetch("/api/v1/endpoints/statuses", {
|
||||
method: "GET",
|
||||
cache: "no-cache",
|
||||
})
|
||||
.then((response) => {
|
||||
// Apply filtering by groups, if defined
|
||||
if (this.item.groups) {
|
||||
response = response?.filter((job) => {
|
||||
return this.item.groups.includes(job.group) === true;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialise counts, avg times
|
||||
this.total = response.length;
|
||||
this.up = 0;
|
||||
|
||||
let totalrestime = 0;
|
||||
let totalresults = 0;
|
||||
|
||||
response.forEach((job) => {
|
||||
if (job.results[job.results.length - 1].success === true) {
|
||||
this.up++;
|
||||
}
|
||||
|
||||
if (!this.item.hideaverages) {
|
||||
// Update array of average times
|
||||
let totalduration = 0;
|
||||
let rescounter = 0;
|
||||
job.results.forEach((res) => {
|
||||
totalduration += parseInt(res.duration, 10) / 1000000;
|
||||
rescounter++;
|
||||
});
|
||||
|
||||
totalrestime += totalduration;
|
||||
totalresults += rescounter;
|
||||
} else {
|
||||
totalrestime = 0;
|
||||
totalresults = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Rest are down
|
||||
this.down = this.total - this.up;
|
||||
|
||||
// Calculate overall average response time
|
||||
this.avgRespTime = (totalrestime / totalresults).toFixed(2);
|
||||
|
||||
// Update representations
|
||||
if (this.up == 0 || this.total == 0) {
|
||||
this.percentageGood = 0;
|
||||
} else {
|
||||
this.percentageGood = Math.round((this.up / this.total) * 100);
|
||||
}
|
||||
|
||||
// Status flag
|
||||
if (this.up == 0 && this.down == 0) {
|
||||
this.status = false;
|
||||
} else if (this.down == this.total) {
|
||||
this.status = "bad";
|
||||
} else if (this.up == this.total) {
|
||||
this.status = "good";
|
||||
} else {
|
||||
this.status = "warn";
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
&.good:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0 0 5px 1px #94e185;
|
||||
}
|
||||
&.warn:before {
|
||||
background-color: #f8a306;
|
||||
border-color: #e1b35e;
|
||||
box-shadow: 0 0 5px 1px #f8a306;
|
||||
}
|
||||
&.bad:before {
|
||||
background-color: #c9404d;
|
||||
border-color: #c42c3b;
|
||||
box-shadow: 0 0 5px 1px #c9404d;
|
||||
}
|
||||
&:before {
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #000;
|
||||
border-radius: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
src/components/services/Miniflux.vue
Normal file
160
src/components/services/Miniflux.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<template>
|
||||
<Generic :item="item">
|
||||
<template #content>
|
||||
<p class="title is-4">{{ item.name }}</p>
|
||||
<p class="subtitle is-6">
|
||||
<template v-if="item.subtitle"> {{ item.subtitle }} </template>
|
||||
<template v-else-if="unreadEntries">
|
||||
<template v-if="unreadFeeds < 2">
|
||||
{{ unreadEntries }} unread
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ unreadEntries }} unread in {{ unreadFeeds }} feeds
|
||||
</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<i v-if="loading" class="fa fa-circle-notch fa-spin"></i>
|
||||
<div v-else-if="style == 'status'" class="status" :class="statusClass">
|
||||
{{ status }}
|
||||
</div>
|
||||
<div v-else class="notifs">
|
||||
<strong v-if="unreadEntries > 0" class="notif unread" title="Unread">
|
||||
{{ unreadEntries }}
|
||||
</strong>
|
||||
<strong
|
||||
v-if="!isHealthy"
|
||||
class="notif errors"
|
||||
title="Connection error to Miniflux API, check url and apikey in config.yml"
|
||||
>
|
||||
?
|
||||
</strong>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
|
||||
export default {
|
||||
name: "Miniflux",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
data: () => ({
|
||||
unreadEntries: 0,
|
||||
unreadFeeds: 0,
|
||||
isHealthy: false,
|
||||
loading: true,
|
||||
style: "status",
|
||||
}),
|
||||
computed: {
|
||||
status: function () {
|
||||
if (!this.isHealthy) {
|
||||
return "Error";
|
||||
}
|
||||
return this.unreadEntries > 0 ? "Unread" : "Online";
|
||||
},
|
||||
statusClass: function () {
|
||||
return this.status.toLowerCase();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const checkInterval = parseInt(this.item.checkInterval, 10) || 0;
|
||||
if (checkInterval > 0) {
|
||||
setInterval(() => this.fetchConfig(), checkInterval);
|
||||
}
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
const headers = {
|
||||
"X-Auth-Token": this.item.apikey,
|
||||
};
|
||||
|
||||
let counters;
|
||||
try {
|
||||
counters = await this.fetch("/v1/feeds/counters", { headers });
|
||||
this.isHealthy = true;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
if (!this.isHealthy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unreads = Object.values(counters.unreads || {});
|
||||
this.unreadFeeds = unreads.length;
|
||||
this.unreadEntries = unreads.reduce((accumulator, value) => {
|
||||
return accumulator + value;
|
||||
}, 0);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
|
||||
&.online:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0 0 5px 1px #94e185;
|
||||
}
|
||||
|
||||
&.unread:before {
|
||||
background-color: #1774ff;
|
||||
border-color: #1774ff;
|
||||
box-shadow: 0 0 5px 1px #1774ff;
|
||||
}
|
||||
|
||||
&.error:before {
|
||||
background-color: #c9404d;
|
||||
border-color: #c42c3b;
|
||||
box-shadow: 0 0 5px 1px #c9404d;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #000;
|
||||
border-radius: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.notifs {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
top: 0.3em;
|
||||
right: 0.5em;
|
||||
|
||||
.notif {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.35em;
|
||||
border-radius: 0.25em;
|
||||
position: relative;
|
||||
margin-left: 0.3em;
|
||||
font-size: 0.8em;
|
||||
|
||||
&.unread {
|
||||
background-color: #4fb5d6;
|
||||
}
|
||||
|
||||
&.errors {
|
||||
background-color: #e51111;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
src/components/services/Transmission.vue
Normal file
181
src/components/services/Transmission.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<Generic :item="item">
|
||||
<template #content>
|
||||
<p class="title is-4">{{ item.name }}</p>
|
||||
<p v-if="item.subtitle" class="subtitle is-6">{{ item.subtitle }}</p>
|
||||
<p v-else class="subtitle is-6">
|
||||
<span v-if="error" class="error">An error has occurred.</span>
|
||||
<template v-else>
|
||||
<span class="down monospace">
|
||||
<p class="fas fa-download"></p>
|
||||
{{ downRate }}
|
||||
</span>
|
||||
<span class="up monospace">
|
||||
<p class="fas fa-upload"></p>
|
||||
{{ upRate }}
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<span v-if="!error" class="count"
|
||||
>{{ count || 0 }}
|
||||
<template v-if="(count || 0) === 1">torrent</template>
|
||||
<template v-else>torrents</template>
|
||||
</span>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
|
||||
// Take the rate in bytes and keep dividing it by 1k until the lowest
|
||||
// value for which we have a unit is determined. Return the value with
|
||||
// up to two decimals as a string and unit/s appended.
|
||||
const displayRate = (rate) => {
|
||||
let unitIndex = 0;
|
||||
|
||||
while (rate > 1000 && unitIndex < units.length - 1) {
|
||||
rate /= 1000;
|
||||
unitIndex++;
|
||||
}
|
||||
return (
|
||||
Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(
|
||||
rate || 0,
|
||||
) + ` ${units[unitIndex]}/s`
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "Transmission",
|
||||
mixins: [service],
|
||||
props: { item: Object },
|
||||
data: () => ({
|
||||
dl: null,
|
||||
ul: null,
|
||||
count: null,
|
||||
error: null,
|
||||
sessionId: null,
|
||||
retry: 0,
|
||||
}),
|
||||
computed: {
|
||||
downRate: function () {
|
||||
return displayRate(this.dl);
|
||||
},
|
||||
upRate: function () {
|
||||
return displayRate(this.ul);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const interval = parseInt(this.item.interval, 10) || 0;
|
||||
|
||||
// Set up interval if configured
|
||||
if (interval > 0) {
|
||||
setInterval(() => this.getStats(), interval);
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
this.getStats();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Makes a request to Transmission RPC API with proper session handling
|
||||
* @param {string} method - The RPC method to call
|
||||
* @returns {Promise<Object>} RPC response
|
||||
*/
|
||||
transmissionRequest: async function (method) {
|
||||
const options = this.getRequestHeaders(method);
|
||||
|
||||
// Add session ID header if we have one
|
||||
if (this.sessionId) {
|
||||
options.headers["X-Transmission-Session-Id"] = this.sessionId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.fetch("transmission/rpc", options);
|
||||
} catch (error) {
|
||||
// Handle Transmission's 409 session requirement
|
||||
if (error.cause.status == 409 && this.retry <= 1) {
|
||||
const sessionId = await this.getSession();
|
||||
if (sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.retry++;
|
||||
return this.transmissionRequest(method);
|
||||
}
|
||||
}
|
||||
console.error("Transmission RPC error:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getRequestHeaders: function (method) {
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ method }),
|
||||
};
|
||||
|
||||
if (this.item.auth) {
|
||||
options.headers["Authorization"] = `Basic ${btoa(this.item.auth)}`;
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
getSession: async function () {
|
||||
try {
|
||||
await this.fetch(
|
||||
"transmission/rpc",
|
||||
this.getRequestHeaders("session-get"),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.cause.status == 409) {
|
||||
return error.cause.headers.get("X-Transmission-Session-Id");
|
||||
}
|
||||
}
|
||||
},
|
||||
getStats: async function () {
|
||||
try {
|
||||
// Get session stats for transfer rates and torrent count
|
||||
const statsResponse = await this.transmissionRequest("session-stats");
|
||||
if (statsResponse?.result !== "success") {
|
||||
throw new Error(
|
||||
`Transmission RPC failed: ${statsResponse?.result || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = statsResponse.arguments;
|
||||
this.dl = stats.downloadSpeed ?? 0;
|
||||
this.ul = stats.uploadSpeed ?? 0;
|
||||
this.count = stats.activeTorrentCount ?? 0;
|
||||
this.error = false;
|
||||
} catch (e) {
|
||||
this.error = true;
|
||||
console.error("Transmission service error:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.error {
|
||||
color: #e51111 !important;
|
||||
}
|
||||
|
||||
.down {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--text);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-weight: 300;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -55,6 +55,7 @@ export default {
|
|||
if (!success) {
|
||||
throw new Error(
|
||||
`Fail to fetch ressource: (${response.status} error)`,
|
||||
{ cause: response },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,28 @@ import process from "process";
|
|||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
import { version } from "./package.json";
|
||||
|
||||
function writeVersionPlugin() {
|
||||
return {
|
||||
name: "write-version",
|
||||
closeBundle() {
|
||||
fs.writeFileSync("dist/VERSION", version);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
build: {
|
||||
assetsDir: "resources",
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
plugins: [
|
||||
writeVersionPlugin(),
|
||||
// Custom plugin to serve dummy-data JSON files without sourcemap injection
|
||||
{
|
||||
name: "dummy-data-json-handler",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue