From 5d29b61e5421bb670e19c0df2cabc8c278e9d090 Mon Sep 17 00:00:00 2001 From: codemastervy Date: Mon, 31 Aug 2026 06:26:14 +1200 Subject: [PATCH] Add an Apps launcher page, configured by a local YAML file A Homer-style sidebar page listing links to anything on the network -- other Docker Compose apps, a router admin page, whatever. List or grid view, same as Files. Deliberately no add/edit UI: the source of truth is ./data/apps.yml, read fresh on every request (no restart needed to see an edit). An entry needs a name and a url; icon is optional -- an emoji, or an image URL. A missing or malformed file shows the exact problem and the expected format on the page itself, rather than silently rendering nothing. Verified locally end to end before opening this PR: the backend running outside Docker with a real apps.yml, all three states (empty, populated, malformed YAML) screenshotted in an actual browser, and the full test suite (51 tests, 11 new) passing. --- .gitignore | 1 + README.md | 45 ++++++++++++++ backend/app/main.py | 2 + backend/app/routes/apps.py | 17 ++++++ backend/app/services/apps.py | 70 ++++++++++++++++++++++ backend/requirements.txt | 1 + backend/tests/test_apps.py | 110 +++++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 3 + frontend/src/lib/api.ts | 7 +++ frontend/src/pages/Apps.tsx | 106 +++++++++++++++++++++++++++++++++ frontend/src/styles.css | 13 +++++ 11 files changed, 375 insertions(+) create mode 100644 backend/app/routes/apps.py create mode 100644 backend/app/services/apps.py create mode 100644 backend/tests/test_apps.py create mode 100644 frontend/src/pages/Apps.tsx diff --git a/.gitignore b/.gitignore index 5206e52..9b4f3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docker-compose.override.yml # OS .DS_Store +*.tsbuildinfo diff --git a/README.md b/README.md index e6b30f2..e54cbd4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Files written from a Mac over SMB, visible immediately in the browser. - [Why sharing is opt-in](#why-sharing-is-opt-in) - [How SMB sharing works under the hood](#how-smb-sharing-works-under-the-hood) - [Connecting from your devices](#connecting-from-your-devices) +- [Apps launcher](#apps-launcher) - [Backing up](#backing-up) - [Migrating from CasaOS](#migrating-from-casaos) - [Stack choice](#stack-choice) @@ -80,6 +81,11 @@ exported, with one-click **Unshare** that genuinely revokes access. Each is created with `nologin` and no home directory, so an SMB credential can never become a shell login. +**Apps.** A Homer-style launcher page listing links to anything on your +network — other Docker Compose apps, your router's admin page, whatever. List +or grid view. There is deliberately no "add app" button in the UI — see +[Apps launcher](#apps-launcher). + --- ## Quick start @@ -377,6 +383,45 @@ older phone SMB clients. --- +## Apps launcher + +A **Homer**-style launcher page in the sidebar: a list or grid of links to +anything on your network — other Docker Compose apps, your router's admin +page, a Proxmox host, whatever. This is not a Docker integration and doesn't +inspect what's actually running; it's just a bookmarks page with icons. + +### There is no "add app" button, on purpose + +Every other page in this dashboard treats configuration as UI state, but this +one is a plain YAML file at `./data/apps.yml` — the same host directory as +everything else persistent. That's deliberate: this list is exactly the kind +of thing you set up once and barely touch, and a flat file is easier to back +up, script, or hand-edit over SSH than a form ever needs to be. + +```yaml +apps: + - name: Portainer + icon: "🐳" + url: http://192.168.1.10:9000 + - name: Plex + icon: https://raw.githubusercontent.com/plexinc/pms-docker/master/plex.png + url: http://192.168.1.10:32400/web + - name: Router Admin + url: http://192.168.1.1 +``` + +- `name` and `url` are required; an entry missing either is skipped (not + fatal to the rest of the file). +- `icon` is optional. Either an emoji/character, or a URL to an image — + anything starting with `http://` or `https://` is rendered as an ``. + With no icon, a generic 🧩 is shown. +- The file is read fresh on every page load — **no restart needed**. Edit it, + refresh the browser. +- Invalid YAML is reported on the page itself, with the exact parser error + and the path it looked at, rather than silently showing nothing. + +--- + ## Backing up `./data` is the only thing here you cannot regenerate. It holds the share diff --git a/backend/app/main.py b/backend/app/main.py index 449fcb7..40ee2c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from . import auth from .config import (ADMIN_PASSWORD, AUTH_ENABLED, DATA_DIR, HOST_PROC, SMB_SHARES_DIR, STORAGE_ROOT) +from .routes import apps as apps_routes from .routes import auth_routes, files, shares, system, users from .services import hostproc, samba, smbusers @@ -39,6 +40,7 @@ app.include_router(files.router) app.include_router(shares.router) app.include_router(users.router) +app.include_router(apps_routes.router) @app.on_event("startup") diff --git a/backend/app/routes/apps.py b/backend/app/routes/apps.py new file mode 100644 index 0000000..ecc4920 --- /dev/null +++ b/backend/app/routes/apps.py @@ -0,0 +1,17 @@ +"""Local app launcher, configured entirely by a YAML file on disk.""" +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends + +from ..auth import require_auth +from ..services import apps + +router = APIRouter(prefix="/api/apps", tags=["apps"], + dependencies=[Depends(require_auth)]) + + +@router.get("") +async def get_apps() -> dict: + return await asyncio.to_thread(apps.list_apps) diff --git a/backend/app/services/apps.py b/backend/app/services/apps.py new file mode 100644 index 0000000..7c1322f --- /dev/null +++ b/backend/app/services/apps.py @@ -0,0 +1,70 @@ +"""A local, YAML-configured app launcher, in the spirit of Homer. + +Nothing here is added or edited through the UI on purpose -- the source of +truth is one file at DATA_DIR/apps.yml, edited directly on the host. It is +read fresh on every request rather than cached, so an edit takes effect on +the next browser refresh with no container restart needed. +""" +from __future__ import annotations + +import logging +from typing import Any, Optional + +import yaml + +from ..config import DATA_DIR + +log = logging.getLogger(__name__) + +APPS_CONFIG = DATA_DIR / "apps.yml" + +EXAMPLE = """\ +apps: + - name: Portainer + icon: "\U0001F433" + url: http://192.168.1.10:9000 + - name: Plex + icon: https://raw.githubusercontent.com/plexinc/pms-docker/master/plex.png + url: http://192.168.1.10:32400/web +""" + + +def _validate(entry: Any, index: int) -> Optional[dict[str, str]]: + if not isinstance(entry, dict): + log.warning("apps.yml entry %d is not a mapping, skipping", index) + return None + name = str(entry.get("name") or "").strip() + url = str(entry.get("url") or "").strip() + icon = str(entry.get("icon") or "").strip() + if not name or not url: + log.warning("apps.yml entry %d is missing name or url, skipping", index) + return None + return {"name": name, "url": url, "icon": icon} + + +def list_apps() -> dict[str, Any]: + if not APPS_CONFIG.exists(): + return {"apps": [], "exists": False, "error": None, + "config_path": str(APPS_CONFIG), "example": EXAMPLE} + + try: + raw = yaml.safe_load(APPS_CONFIG.read_text()) + except yaml.YAMLError as exc: + return {"apps": [], "exists": True, + "error": f"apps.yml is not valid YAML: {exc}", + "config_path": str(APPS_CONFIG), "example": EXAMPLE} + + entries = raw.get("apps") if isinstance(raw, dict) else None + if not isinstance(entries, list): + return {"apps": [], "exists": True, + "error": "apps.yml must have a top-level 'apps:' list", + "config_path": str(APPS_CONFIG), "example": EXAMPLE} + + apps: list[dict[str, str]] = [] + for i, entry in enumerate(entries): + validated = _validate(entry, i) + if validated: + apps.append(validated) + + return {"apps": apps, "exists": True, "error": None, + "config_path": str(APPS_CONFIG), "example": EXAMPLE} diff --git a/backend/requirements.txt b/backend/requirements.txt index e58144e..e844b8e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ python-multipart==0.0.20 pydantic==2.10.5 itsdangerous==2.2.0 passlib[bcrypt]==1.7.4 +PyYAML==6.0.2 diff --git a/backend/tests/test_apps.py b/backend/tests/test_apps.py new file mode 100644 index 0000000..7bba049 --- /dev/null +++ b/backend/tests/test_apps.py @@ -0,0 +1,110 @@ +"""The apps.yml launcher: nothing here is editable through the UI, so the +file-parsing behavior IS the feature -- this is what actually decides +whether a person's homer-style config shows up correctly.""" +import pytest + +from app.services import apps + + +@pytest.fixture() +def config(tmp_path, monkeypatch): + path = tmp_path / "apps.yml" + monkeypatch.setattr(apps, "APPS_CONFIG", path) + return path + + +def test_no_file_yet_is_not_an_error(config): + result = apps.list_apps() + assert result["exists"] is False + assert result["error"] is None + assert result["apps"] == [] + + +def test_a_normal_config_parses(config): + config.write_text(""" +apps: + - name: Portainer + icon: "🐳" + url: http://192.168.1.10:9000 + - name: Plex + icon: https://example.com/plex.png + url: http://192.168.1.10:32400/web +""") + result = apps.list_apps() + assert result["exists"] is True + assert result["error"] is None + assert result["apps"] == [ + {"name": "Portainer", "icon": "🐳", "url": "http://192.168.1.10:9000"}, + {"name": "Plex", "icon": "https://example.com/plex.png", + "url": "http://192.168.1.10:32400/web"}, + ] + + +def test_icon_is_optional(config): + config.write_text("apps:\n - name: Router\n url: http://192.168.1.1\n") + result = apps.list_apps() + assert result["apps"] == [{"name": "Router", "icon": "", "url": "http://192.168.1.1"}] + + +def test_an_entry_missing_url_is_skipped_not_fatal(config): + config.write_text(""" +apps: + - name: Broken entry + - name: Good entry + url: http://192.168.1.20 +""") + result = apps.list_apps() + assert result["error"] is None + assert [a["name"] for a in result["apps"]] == ["Good entry"] + + +def test_an_entry_missing_name_is_skipped_not_fatal(config): + config.write_text(""" +apps: + - url: http://192.168.1.30 + - name: Good entry + url: http://192.168.1.20 +""") + result = apps.list_apps() + assert [a["name"] for a in result["apps"]] == ["Good entry"] + + +def test_a_non_mapping_entry_is_skipped_not_fatal(config): + config.write_text("apps:\n - just a string\n - name: Good\n url: http://x\n") + result = apps.list_apps() + assert [a["name"] for a in result["apps"]] == ["Good"] + + +def test_malformed_yaml_reports_an_error_rather_than_crashing(config): + config.write_text("apps: [this is not: valid: yaml") + result = apps.list_apps() + assert result["exists"] is True + assert result["apps"] == [] + assert "not valid YAML" in result["error"] + + +def test_missing_top_level_apps_key_reports_an_error(config): + config.write_text("something_else:\n - foo\n") + result = apps.list_apps() + assert result["apps"] == [] + assert "apps:" in result["error"] + + +def test_apps_key_that_is_not_a_list_reports_an_error(config): + config.write_text("apps: just a string\n") + result = apps.list_apps() + assert result["apps"] == [] + assert result["error"] is not None + + +def test_empty_apps_list_is_not_an_error(config): + config.write_text("apps: []\n") + result = apps.list_apps() + assert result["error"] is None + assert result["apps"] == [] + + +def test_names_and_urls_are_whitespace_trimmed(config): + config.write_text('apps:\n - name: " Spacey "\n url: " http://x "\n') + result = apps.list_apps() + assert result["apps"] == [{"name": "Spacey", "icon": "", "url": "http://x"}] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1406225..f38a9c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { api } from './lib/api' import { ToastHost } from './components/Toast' +import { Apps } from './pages/Apps' import { Dashboard } from './pages/Dashboard' import { Files } from './pages/Files' import { Shares } from './pages/Shares' @@ -15,6 +16,7 @@ const NAV = [ { to: '/files', label: 'Files', icon: '🗂️', end: false }, { to: '/shares', label: 'Shares', icon: '🔗', end: false }, { to: '/users', label: 'Users', icon: '👥', end: false }, + { to: '/apps', label: 'Apps', icon: '🧩', end: false }, ] export default function App() { @@ -114,6 +116,7 @@ export default function App() { setMenuOpen(true)} />} /> setMenuOpen(true)} />} /> setMenuOpen(true)} />} /> + setMenuOpen(true)} />} /> } /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index abc4c9b..d10a9b5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -129,8 +129,15 @@ export interface User { // ---------------------------------------------------------------- endpoints +export interface AppLink { name: string; icon: string; url: string } +export interface AppsResponse { + apps: AppLink[]; exists: boolean; error: string | null + config_path: string; example: string +} + export const api = { authStatus: () => get<{ auth_required: boolean; configured: boolean; authenticated: boolean }>('/api/auth/status'), + apps: () => get('/api/apps'), login: (password: string) => post<{ authenticated: boolean }>('/api/auth/login', { password }), logout: () => post<{ authenticated: boolean }>('/api/auth/logout'), diff --git a/frontend/src/pages/Apps.tsx b/frontend/src/pages/Apps.tsx new file mode 100644 index 0000000..1b4966f --- /dev/null +++ b/frontend/src/pages/Apps.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import { TopBar } from '../App' +import { api, AppLink } from '../lib/api' + +type ViewMode = 'list' | 'grid' + +/** An app's icon: an emoji/character, or an image URL -- Homer-style. */ +function AppIcon({ icon }: { icon: string }) { + if (!icon) return 🧩 + if (icon.startsWith('http://') || icon.startsWith('https://')) { + return ( + { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden' }} /> + ) + } + return {icon} +} + +export function Apps({ onMenu }: { onMenu: () => void }) { + const [apps, setApps] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [exists, setExists] = useState(true) + const [configPath, setConfigPath] = useState('') + const [example, setExample] = useState('') + const [view, setView] = useState( + () => (localStorage.getItem('nasdash-apps-view') as ViewMode) ?? 'grid') + + useEffect(() => { localStorage.setItem('nasdash-apps-view', view) }, [view]) + + useEffect(() => { + let cancelled = false + api.apps() + .then((r) => { + if (cancelled) return + setApps(r.apps); setError(r.error); setExists(r.exists) + setConfigPath(r.config_path); setExample(r.example) + }) + .catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : 'Could not load apps') }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, []) + + const empty = !loading && !error && (!exists || apps.length === 0) + + return ( + <> + + + + + +
+ {loading ? ( +
+ ) : error ? ( +
+ {configPath} has a problem: {error} +
+ ) : empty ? ( +
+ 🧩 + {!exists ? 'No apps configured yet.' : 'apps.yml has no apps listed.'} +
+ Create {configPath} on the host — it's the + same directory as the share registry — with something like: +
+
{example}
+
+ No restart needed — refresh this page after saving. +
+
+ ) : view === 'list' ? ( +
+ {apps.map((a) => ( + + + + {a.name}{a.url} + + + ))} +
+ ) : ( +
+ {apps.map((a) => ( + +
+
{a.name}
+
+ ))} +
+ )} +
+ + ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7bc000b..cf5eb93 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -276,6 +276,19 @@ button, input, select, textarea { font: inherit; color: inherit; } .file-row .name button:hover { color: var(--accent); text-decoration: underline; } .file-row .size, .file-row .date { color: var(--text-muted); font-size: 12.5px; } +.app-row { + display: grid; + grid-template-columns: 34px 1fr; + align-items: center; gap: 12px; + padding: 10px 14px; border-bottom: 1px solid var(--border); + text-decoration: none; color: inherit; +} +.app-row:last-child { border-bottom: none; } +.app-row:hover { background: var(--card-hover); } +.app-row .icon-glyph { font-size: 19px; text-align: center; } +.app-row .icon-img { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; } +.app-row .app-url { color: var(--text-faint); font-size: 12px; margin-left: 8px; } + .file-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));