Initial commit — Atlus web desktop environment for SBCs

Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-14 16:53:46 -05:00
commit f9743bb29a
45 changed files with 6556 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
venv/
.venv/
# Atlus runtime
.atlus_data/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Build artifacts
atlus-*.tar.gz
# Claude dev config
.claude/

514
ATLUS_CONTEXT.md Normal file
View file

@ -0,0 +1,514 @@
# Atlus — Project Context
## What Is Atlus?
Atlus is a lightweight, web-based desktop environment designed for headless single-board computers (SBCs) such as the Orange Pi, Raspberry Pi, and similar ARM-based Linux systems. It provides a browser-accessible interface that replaces the need for SSH or VNC for everyday system management tasks.
The goal is not to replicate a full desktop environment — it is to provide the *right* tools for managing a headless SBC in a way that feels natural, fast, and accessible from any device on the local network.
---
## Problem Statement
Headless SBCs are increasingly used as always-on servers, astrophotography rigs, NAS boxes, home automation hubs, and more. Current management options are:
- **SSH** — powerful but unfriendly for non-developers and tedious for routine tasks
- **VNC / noVNC** — requires a full desktop environment, heavy, slow, not purpose-built
- **Cockpit / Webmin** — generic server admin tools, not tailored to SBC use cases, complex UI
There is no lightweight, purpose-built web desktop for SBCs that feels like an OS rather than a server admin panel.
---
## Core Philosophy
- **Web-native first** — no X server required for core functionality
- **Purposefully minimal** — only the tools that matter for headless SBC management
- **Locally hosted** — runs entirely on the SBC, no cloud dependency
- **Accessible from anything** — phone, tablet, laptop — any browser on the local network
- **Tiling over floating** — apps open in the center stage, not as draggable windows
- **Extensible** — apps/widgets can be added as plugins
---
## Target Users
- Makers and hobbyists running headless SBCs for specific purposes (astrophotography, NAS, IoT, etc.)
- Home lab users who want lightweight management without full desktop overhead
- Anyone who currently lives in SSH and wishes there was something better
---
## ✅ Finalized Layout — Three-Column Shell
The Atlus UI is a fixed three-column layout inspired by tiling window managers (awesomewm, i3). There are no draggable or floating windows. Everything is tiled, cascaded, or tabbed within the center stage.
```
┌────────┬─────────────────────────────┬────────────────┐
│ │ Stage Tab Bar │ │
│ Left ├─────────────────────────────┤ Right Panel │
│ Dock │ │ │
│ │ Center Stage │ - Hostname │
│ App │ (active app view) │ - Date/Time │
│ Icons │ │ - Network │
│ │ One app at a time, │ - CPU/RAM/ │
│ (vert) │ or tiled side-by-side │ Disk/Temp │
│ │ │ - Services │
│ Gear ├─────────────────────────────┤ (toggles + │
│ (foot) │ Layout Controls (tab bar) │ open → ) │
└────────┴─────────────────────────────┴────────────────┘
```
### Left Dock
- Vertical strip of app launcher icons — no labels, tooltip on hover
- Top: Atlus "A" logo — opens a system menu (shutdown, restart, about)
- Core apps listed in order (configurable in settings)
- Separator line between core apps and plugins
- Bottom: Settings gear icon (always pinned)
- Active app highlighted with accent border/background
- Badge dot on plugin icons when they have status to report (e.g. ASI Bridge syncing)
### Center Stage
- The primary content area — full height, fills remaining width
- One app visible at a time by default
- **Tab bar** at top shows all open apps; click to switch (previous app minimizes)
- **Layout toggle buttons** (top-right of tab bar) switch between:
- Single pane (full width)
- Horizontal split (two panes side-by-side, 50/50 default)
- Vertical split (stacked, stretch goal)
- Each pane has its own macOS-style titlebar (traffic light dots + title)
- When a second app is opened in split mode, it takes the secondary pane
### Right Panel
- Always visible, fixed width (~220px), never replaced by an app
- **Top section:** Hostname, date, time (live), WiFi name + status, Ethernet status
- **Middle section:** Live system stats — CPU %, Memory used/total, Storage %, CPU temp
- Each stat: compact uppercase label + current value + colored progress bar
- Colors: green (healthy), amber (moderate), red (high)
- **Bottom section:** Services list
- Each service: toggle (on/off), name, ↗ open button
- Toggle maps to `systemctl start/stop`
- ↗ opens that service's detail/control view in the center stage
- Services list is configurable per deployment
---
## Architecture Overview
### Backend Daemon
- Language: **Python (FastAPI + asyncio)**
- Runs as a systemd service on boot
- Exposes a REST API and WebSocket endpoint
- Responsibilities:
- File system access and management
- Systemd service control (start/stop/restart/status)
- Process/task enumeration
- System stats (CPU, RAM, disk, network, temperature) via psutil
- Notification/event bus (watches logs, mount points, service states)
- X11 app process spawner (Xvfb + x11vnc per app — Phase 4 only)
### Frontend
- Language: **Vanilla JS + CSS** (no framework — keep it lightweight and portable)
- No build step — served directly as static files by the FastAPI backend
- Communicates via REST for actions, WebSocket for live stat updates
- CSS custom properties for theming
- IBM Plex Mono for system data; Inter for UI chrome
### X11 App Embedding (Phase 4 — Optional/Advanced)
- For GUI apps that require X (e.g., Nextcloud desktop client)
- Each app spawns its own Xvfb virtual framebuffer
- x11vnc streams that framebuffer over WebSocket via noVNC
- Embedded as a pane in the center stage — not a floating window
- Isolated per-app — no shared display, no full desktop environment needed
---
## Built-in Apps (Core)
### ⚙️ System Settings
The unified configuration hub for the SBC. Organized into sections:
- **General** — System name/hostname, date & time (manual or NTP), timezone
- **Users** — User management, password changes, add/remove users
- **Security** — Screen lock timeout, session timeout, auth settings
- **Applications** — Choose which apps appear in the dock; enable/disable on startup
- **Services** — Which systemd services appear in the right panel; autostart configuration
- **Network** — Per-interface settings (WiFi SSID/password, static IP vs DHCP, DNS); shown for all detected interfaces (eth0, wlan0, etc.)
- **Storage / Automount** — USB automount rules, mount point configuration, eject behavior
- **About** — Atlus version, system info, OS details
---
### 📟 Terminal
A fully functional web-based terminal. No restrictions, no simplifications — behaves identically to SSH or a local terminal session. Powered by xterm.js on the frontend and ptyprocess on the backend for a real PTY.
#### Custom On-Screen Keyboard (Critical)
The system keyboard (iOS/Android) is **never triggered**. Atlus uses its own purpose-built on-screen keyboard for all terminal input. This is non-negotiable — the native keyboard is hostile to terminal use (autocorrect, autocapitalize, no Escape, no function keys, unpredictable behavior).
**How it works technically:**
- The xterm.js terminal canvas is focused but never triggers a system keyboard (it is not an `<input>` or `<textarea>`)
- All key input is intercepted via the custom keyboard UI — keys send characters directly to the terminal via `terminal.input(char)` and escape sequences for special keys
- The terminal pane never calls `.focus()` on any element that would summon the system keyboard
- `inputmode="none"` set on any focusable elements within the terminal pane as a belt-and-suspenders measure
**Custom keyboard layout — three rows:**
```
Row 1 (special/modifier):
[ Esc ] [ Tab ] [ Ctrl ] [ Alt ] [ ` ] [ ~ ] [ | ] [ \ ] [ / ]
Row 2 (function keys, scrollable):
[ F1 ] [ F2 ] [ F3 ] [ F4 ] [ F5 ] [ F6 ] [ F7 ] [ F8 ] [ F9 ] [ F10 ] [ F11 ] [ F12 ]
Row 3 (navigation):
[ ↑ ] [ ↓ ] [ ← ] [ → ] [ Home ] [ End ] [ PgUp ] [ PgDn ] [ Del ]
```
**Modifier key behavior:**
- Ctrl, Alt are sticky — tap once to arm, next key sends the combo (e.g. Ctrl → C sends `\x03`)
- Armed modifier keys are visually highlighted
- Double-tap a modifier to lock it (Ctrl+lock for vim, etc.)
**Standard alphanumeric input:**
- A compact QWERTY layout below the special rows handles letter/number/symbol input
- Numbers row across the top of the QWERTY section
- Shift key for capitals — also sticky
- Common terminal symbols promoted to easy reach: `$`, `#`, `>`, `-`, `_`, `.`
**Keyboard can be:**
- Toggled show/hide via a keyboard icon button in the terminal pane titlebar
- Resized (compact vs. full) — compact shows only the special row + QWERTY condensed
- On physical keyboard connected to iPad: custom keyboard hides, physical keys work natively through xterm.js
**Other terminal features:**
- Full color output, cursor control, scrollback
- Resize-aware (terminal resizes with the pane, keyboard height accounted for)
- Copy: long-press selection in terminal → copy action
- Paste: paste button on keyboard toolbar injects clipboard content via `terminal.input()`
- Multiple terminal tabs within the terminal pane (Phase 2)
---
### 🗂 File Manager
Unique dual-pane design. Key characteristics:
- **Single or dual folder view** — toggle between one panel (full width) and two side-by-side panels
- **List view only** — no icon grid; each row shows: icon type indicator, filename, size, modified date, permissions
- **Drag and drop** — files can be dragged between panels or into folders within a panel
- **Sidebar** — shows filesystem tree: home, root, and all detected mounts (USB, SMB, etc.)
- **Features per file/selection:**
- Preview (text, image, FITS header for astrophotography files)
- Compress / extract (zip, tar.gz)
- Copy, move, rename, delete
- Properties (permissions, owner, size on disk)
- **Mount management** — right-click a mount point to unmount; USB devices show an eject option
- **Path bar** — editable breadcrumb path at the top of each panel
- **No thumbnails** — performance-first; list only
---
### 📊 Task Manager
htop-inspired, but scoped and web-native. Shows:
- Process list: PID, name, CPU %, MEM %, status, user
- Sortable columns
- Kill / signal process action
- Filter to show only Atlus-relevant processes (or toggle to show all)
- Summary bar at top: total CPU, RAM, load average — mirrors the right panel stats but with more detail
- Auto-refreshes every 2 seconds via WebSocket
---
### 🌐 Network Monitor
Per-interface detail view:
- Interface name, MAC, state (up/down)
- IP addresses (IPv4 + IPv6)
- Gateway, DNS servers
- Live throughput (bytes in/out per second)
- For WiFi: SSID, signal strength, frequency band
---
### 🔧 Settings
*(see System Settings above — Settings is the dock entry point into the System Settings app)*
---
### Plugin Apps (Installable)
| App | Dock Icon | Description |
|---|---|---|
| **ASI Bridge** | `🔭` | ASI Air mount status, transfer log, Nextcloud sync, session summary |
| **Log Viewer** | `📝` | Real-time tail of any systemd service log via WebSocket |
| **INDI Control** | *(future)* | INDI server management for astrophotography rigs |
---
## Design Language
### Aesthetic
- Dark, utilitarian, refined — high-end terminal meets modern OS
- Inspired by: awesomewm, macOS terminal, Linear app
- Flat — no gradients, no heavy shadows, no decorative elements
### Typography
- **IBM Plex Mono** — all system data, file paths, IPs, timestamps, terminal output
- **Inter** — UI chrome, labels, app names, navigation
- Two weights only: 400 regular, 500 medium
### Color System
- Background layers (dark → light): `#0d0f14``#111318``#161b27`
- Borders: `#1e2130` (structural), `#2a2d36` (component-level)
- Text: `#c8ccd8` (primary), `#8891a8` (secondary), `#4a5068` (muted), `#2e3348` (ghost)
- Accent (active/selected): `#6ea6f0`
- Status: `#3ab86a` (ok/green), `#e09a2a` (warning/amber), `#e05a4a` (error/red)
### Motion
- Subtle only: tab switches, service toggle state, stat bar transitions
- Status values update live via WebSocket — no manual refresh needed
---
## ✅ Touch-First Design — Primary Target: iPad & Tablets
Atlus is **touch-first**. Desktop mouse/keyboard is secondary. Every interaction must be comfortable with a finger on a tablet screen. This affects every component.
### Touch Target Sizing
- **Minimum tap target: 48×48px** for all interactive elements (Apple HIG / Material guidelines)
- Dock icons: 56×56px minimum hit area
- File rows: minimum 48px tall
- Settings rows: minimum 48px tall
- Service toggles: at least 44px tall hit area (even if visually smaller)
- Tab bar items: minimum 44px tall
- All buttons: minimum 44px tall
### No Hover Dependency
- **No tooltips as primary labels** — dock icons must show a label below the icon (not on hover)
- No hover-only states for anything critical
- All context menus triggered by **long-press**, not right-click
- Hover styles may exist as a progressive enhancement for mouse users, but never as the only way to discover functionality
### Gesture Support
- **Swipe left/right** on the center stage to switch between open apps (like iOS app switching)
- **Swipe right from left edge** to reveal/toggle the dock on smaller screens
- **Swipe left from right edge** to reveal/toggle the right panel on smaller screens
- **Long-press** on files for context menu (cut, copy, rename, delete, compress, properties)
- **Long-press** on dock icon for app options (remove from dock, move)
- **Pull down** on file list to refresh directory
- **Pinch to zoom** in terminal and file preview — browser native
### Layout Adaptations for Touch
#### Landscape (primary tablet orientation):
- Three-column layout as designed — dock (72px wide), stage (flex), right panel (240px)
- Dock shows icon + label underneath (not tooltip)
- All rows/items generously spaced
#### Portrait / Narrow screens:
- Dock collapses to a slide-in drawer (swipe from left or tap hamburger)
- Right panel collapses to a slide-in drawer (swipe from right or tap info button in tab bar)
- Center stage goes full-width
- Tab bar remains visible at top
#### Phone (stretch goal, not v1):
- Bottom tab bar replaces left dock
- Right panel accessible via bottom sheet
### File Manager — Touch Adaptations
- Row height: 52px minimum
- Drag and drop uses **touch drag** (touchstart/touchmove/touchend events) not mouse drag
- Selection via long-press + drag, or tap checkbox mode
- Checkbox mode: tap a file to enter selection mode, then tap others to multi-select
- Context menu on long-press: open, preview, copy, move, rename, compress, delete
- No right-click context menu (or: right-click works as fallback for mouse users)
### Terminal — Touch Adaptations
- Software keyboard appears automatically on tap
- Toolbar of common keys above the software keyboard: Escape, Tab, Ctrl, arrow keys, pipe, tilde
- Pinch to adjust font size
- Two-finger scroll for scrollback
### Settings — Touch Adaptations
- All form rows 52px+ tall
- Toggle switches 51×31px (standard iOS-style)
- Sidebar nav items 48px tall minimum
- Save/cancel buttons large and thumb-reachable (bottom of content, full-width or right-aligned with generous padding)
### Right Panel — Touch Adaptations
- Service rows 52px tall — the toggle and ↗ button are both easy targets
- On narrow screens: collapses, accessible via swipe or button
### General Touch Rules
- No double-click interactions — everything single tap or long-press
- Scroll areas must be `-webkit-overflow-scrolling: touch` (momentum scrolling)
- `touch-action` set appropriately on draggable items
- Input fields: `font-size: 16px` minimum to prevent iOS auto-zoom on focus
- Avoid `position: fixed` where possible — use `position: sticky` for headers/toolbars
- All modals/dialogs centered and thumb-reachable, not tiny desktop-style popups
---
## Astrophotography Reference Deployment
The initial real-world deployment target:
```
[ASI Air] ──ethernet──▶ [Orange Pi 5 Max / Atlus] ──nextcloud──▶ [Home Server]
192.168.10.120 192.168.10.121 (eth to ASI Air)
192.168.1.121 (wifi to home network)
```
The **ASI Bridge** plugin panel shows:
- ASI Air SMB mount status (connected / disconnected / error)
- Nextcloud client sync status (idle / syncing / error)
- Live transfer log (filename, size, timestamp, sync result)
- Session summary (frames captured tonight, total GB transferred)
- Quick actions: remount share, restart sync service, clear log
---
## Tech Stack
| Component | Technology |
|---|---|
| Backend | Python 3.11+, FastAPI, asyncio, uvicorn |
| WebSockets | FastAPI WebSocket |
| System stats | psutil |
| Terminal | xterm.js (frontend) + ptyprocess (backend) |
| File ops | Python pathlib / os |
| Service mgmt | subprocess + systemctl |
| X11 embedding | Xvfb + x11vnc + noVNC (Phase 4 only) |
| Frontend | Vanilla JS, CSS custom properties, no build step |
| Fonts | IBM Plex Mono + Inter (Google Fonts CDN or self-hosted) |
| Packaging | Single systemd service + `install.sh` bootstrap script |
| Target OS | Debian/Ubuntu ARM64 (DietPi, Armbian, Raspberry Pi OS) |
---
## Project Phases
### Phase 1 — Core Shell *(start here)*
- [ ] Repo and project structure setup
- [ ] FastAPI backend — `/api/stats` endpoint (CPU, RAM, disk, temp, network IPs)
- [ ] WebSocket endpoint — push live stats every 2s
- [ ] Static frontend served by FastAPI
- [ ] Three-column shell layout (dock, stage, right panel) — HTML/CSS skeleton
- [ ] Right panel wired to live WebSocket stats + clock
- [ ] Left dock with app icons and active state
- [ ] Terminal app — xterm.js frontend + ptyprocess PTY backend
- [ ] App switching — clicking dock icon loads app into center stage
### Phase 2 — File Manager + Service Control
- [ ] File Manager — directory listing, navigate, breadcrumb path, sidebar mounts
- [ ] Service Manager — list systemd units, start/stop/restart via API
- [ ] Right panel service toggles wired to real systemctl calls
- [ ] Log Viewer — real-time tail of service logs via WebSocket stream
- [ ] Split pane layout — two apps tiled side by side
### Phase 3 — Plugin System + ASI Bridge
- [ ] Plugin architecture — self-contained module (backend route + frontend panel JS)
- [ ] ASI Bridge plugin — mount watcher, transfer log, Nextcloud sync status
- [ ] Settings app — configure dock items, services list, hostname display
### Phase 4 — X11 App Embedding *(advanced)*
- [ ] Xvfb process spawner via API call
- [ ] x11vnc per-app streaming to WebSocket
- [ ] noVNC embedded as a center stage pane
- [ ] Nextcloud Qt client embedded as live X11 panel
---
## ✅ Finalized Project Decisions
| Decision | Answer |
|---|---|
| **Project type** | Single monorepo — frontend served by FastAPI backend |
| **Framing** | Full desktop environment — not a server admin panel |
| **Authentication** | Username + password (session token after login) |
| **Default port** | 7779 |
| **Installation** | `curl \| bash` installer script |
| **License** | GPL |
| **Theming** | Dark only for v1 — light mode planned, CSS variables structured for it from day one |
| **Target OS** | DietPi (Debian ARM64) — installer targets DietPi, Armbian, Ubuntu Server as fallbacks |
| **Terminal sessions** | Multiple — tabbed terminals within the terminal pane |
| **Boot / login** | Branded login/boot screen before the desktop loads |
| **Filesystem access** | Full — same as any desktop environment |
| **Terminal user** | Runs as the logged-in system user |
| **Multiple clients** | Yes — multiple browsers/devices can connect simultaneously, all receive live WebSocket updates |
| **File operations** | Confirm destructive actions only (delete, overwrite) |
| **Service detection** | Auto-detect all systemd services — user manages visibility in Settings |
| **ASI Bridge when disconnected** | Show disconnected badge — still openable, shows a disconnected state screen |
| **Plugin (ASI Bridge)** | Built-in from day one, bundled with Atlus core |
| **Dock order** | Hard-coded for v1, configurable in Settings v2 |
| **Right panel width** | Fixed 240px for v1 |
---
## Project Structure (Monorepo)
```
atlus/
├── README.md
├── LICENSE # GPL
├── install.sh # curl | bash installer
├── atlus.service # systemd unit file template
├── backend/
│ ├── main.py # FastAPI app entry point
│ ├── config.py # Config, paths, constants
│ ├── auth.py # Username/password auth, session tokens
│ ├── routers/
│ │ ├── stats.py # /api/stats — CPU, RAM, disk, temp, network
│ │ ├── files.py # /api/files — filesystem operations
│ │ ├── services.py # /api/services — systemd management
│ │ ├── processes.py # /api/processes — task manager
│ │ ├── terminal.py # /api/terminal — PTY websocket
│ │ ├── settings.py # /api/settings — read/write config
│ │ └── plugins/
│ │ └── asi_bridge.py # ASI Air bridge plugin routes
│ ├── ws/
│ │ └── manager.py # WebSocket connection manager (multi-client broadcast)
│ └── requirements.txt
├── frontend/
│ ├── index.html # Login/boot screen
│ ├── desktop.html # Main desktop shell
│ ├── css/
│ │ ├── variables.css # CSS custom properties (colors, spacing, typography)
│ │ ├── shell.css # Three-column layout
│ │ ├── dock.css
│ │ ├── panel.css
│ │ ├── stage.css
│ │ ├── keyboard.css # Custom on-screen keyboard
│ │ └── apps/
│ │ ├── terminal.css
│ │ ├── files.css
│ │ ├── settings.css
│ │ └── tasks.css
│ ├── js/
│ │ ├── atlus.js # Core shell, app switching, WebSocket client
│ │ ├── auth.js # Login screen logic
│ │ ├── keyboard.js # Custom on-screen keyboard
│ │ └── apps/
│ │ ├── terminal.js # xterm.js + PTY integration
│ │ ├── files.js # File manager
│ │ ├── settings.js # Settings app
│ │ ├── tasks.js # Task manager
│ │ ├── services.js # Service manager
│ │ └── asi_bridge.js # ASI Bridge plugin panel
│ └── assets/
│ └── atlus-logo.svg
└── plugins/ # Future third-party plugins live here
```
---
## Open Questions (Deferred — Not Blocking v1)
- **Portrait collapse behavior**: Slide-in drawers vs. bottom tab bar — decide when building responsive CSS
- **Terminal keyboard quick-key row**: Exact key selection — decide during keyboard build
---
*Last updated: 2026-03-13*
*Project status: Planning complete ✅ — all decisions finalized — ready to build Phase 1*

29
LICENSE Normal file
View file

@ -0,0 +1,29 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
For the complete license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

29
atlus.service Normal file
View file

@ -0,0 +1,29 @@
[Unit]
Description=Atlus — Web Desktop Environment
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/atlus/venv/bin/python -m uvicorn backend.main:app --host 0.0.0.0 --port 7779
WorkingDirectory=/opt/atlus
Restart=always
RestartSec=5
User=root
# Environment
Environment=ATLUS_CONFIG_DIR=/etc/atlus
Environment=ATLUS_DATA_DIR=/var/lib/atlus
# Hardening (permissive — we need root for PAM/setuid/systemctl)
ProtectSystem=false
ProtectHome=false
NoNewPrivileges=false
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=atlus
[Install]
WantedBy=multi-user.target

0
backend/__init__.py Normal file
View file

131
backend/auth.py Normal file
View file

@ -0,0 +1,131 @@
"""PAM authentication and JWT token management for Atlus."""
import logging
import platform
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, WebSocket, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.config import (
JWT_ALGORITHM,
JWT_EXPIRY_HOURS,
get_jwt_secret,
is_token_revoked,
revoke_token,
)
log = logging.getLogger("atlus.auth")
_bearer = HTTPBearer()
# PAM auth is only used on Linux — fall back to dev mode on macOS/other
_use_pam = False
_pam_instance = None
if platform.system() == "Linux":
try:
import pam
_pam_instance = pam.pam()
_use_pam = True
except (ImportError, OSError):
log.warning("PAM module not available — running in dev mode")
else:
log.warning("Non-Linux platform (%s) — running in dev mode (any credentials accepted)", platform.system())
# ---------------------------------------------------------------------------
# PAM
# ---------------------------------------------------------------------------
def authenticate_user(username: str, password: str) -> bool:
"""Validate credentials against Linux PAM.
On non-Linux systems (dev mode), accepts any non-empty credentials.
"""
if not username or not password:
return False
if _use_pam and _pam_instance:
return _pam_instance.authenticate(username, password, service="login")
# Dev mode — accept anything
return True
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
def create_token(username: str) -> tuple[str, str]:
"""Issue a signed JWT. Returns (token, jti)."""
jti = uuid.uuid4().hex
now = datetime.now(timezone.utc)
payload = {
"sub": username,
"jti": jti,
"iat": now,
"exp": now + timedelta(hours=JWT_EXPIRY_HOURS),
}
token = jwt.encode(payload, get_jwt_secret(), algorithm=JWT_ALGORITHM)
return token, jti
def decode_token(token: str) -> dict:
"""Decode and validate a JWT. Raises on any failure."""
try:
payload = jwt.decode(
token, get_jwt_secret(), algorithms=[JWT_ALGORITHM]
)
except jwt.ExpiredSignatureError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
if is_token_revoked(payload.get("jti", "")):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token revoked")
return payload
def logout(token: str) -> None:
"""Revoke a token so it cannot be reused."""
try:
payload = jwt.decode(
token, get_jwt_secret(), algorithms=[JWT_ALGORITHM],
options={"verify_exp": False},
)
revoke_token(payload.get("jti", ""))
except jwt.InvalidTokenError:
pass
# ---------------------------------------------------------------------------
# FastAPI dependencies
# ---------------------------------------------------------------------------
def get_current_user(
creds: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)],
) -> str:
"""Dependency — extracts and validates the bearer token, returns username."""
payload = decode_token(creds.credentials)
return payload["sub"]
async def ws_authenticate(websocket: WebSocket) -> str:
"""Authenticate a WebSocket connection via token query param.
Usage in route:
@router.websocket("/ws/something")
async def ws(websocket: WebSocket):
username = await ws_authenticate(websocket)
...
"""
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=4001, reason="Missing token")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token")
try:
payload = decode_token(token)
except HTTPException:
await websocket.close(code=4001, reason="Invalid token")
raise
return payload["sub"]

103
backend/config.py Normal file
View file

@ -0,0 +1,103 @@
"""Atlus configuration — paths, JWT settings, defaults."""
import os
import json
import secrets
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent.parent # repo root
FRONTEND_DIR = BASE_DIR / "frontend"
_default_config = "/etc/atlus" if os.path.exists("/etc") and os.access("/etc", os.W_OK) else str(BASE_DIR / ".atlus_data")
_default_data = "/var/lib/atlus" if os.path.exists("/var/lib") and os.access("/var/lib", os.W_OK) else str(BASE_DIR / ".atlus_data")
CONFIG_DIR = Path(os.environ.get("ATLUS_CONFIG_DIR", _default_config))
DATA_DIR = Path(os.environ.get("ATLUS_DATA_DIR", _default_data))
USER_CONFIG_FILE = CONFIG_DIR / "atlus.json"
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
HOST = os.environ.get("ATLUS_HOST", "0.0.0.0")
PORT = int(os.environ.get("ATLUS_PORT", "7779"))
# ---------------------------------------------------------------------------
# JWT
# ---------------------------------------------------------------------------
JWT_SECRET_FILE = DATA_DIR / "jwt_secret"
JWT_ALGORITHM = "HS256"
JWT_EXPIRY_HOURS = int(os.environ.get("ATLUS_JWT_EXPIRY_HOURS", "24"))
# Revoked tokens (in-memory set, cleared on restart)
_revoked_tokens: set[str] = set()
def get_jwt_secret() -> str:
"""Return persistent JWT secret, generating one on first run."""
if JWT_SECRET_FILE.exists():
return JWT_SECRET_FILE.read_text().strip()
JWT_SECRET_FILE.parent.mkdir(parents=True, exist_ok=True)
secret = secrets.token_hex(32)
JWT_SECRET_FILE.write_text(secret)
JWT_SECRET_FILE.chmod(0o600)
return secret
def revoke_token(jti: str) -> None:
_revoked_tokens.add(jti)
def is_token_revoked(jti: str) -> bool:
return jti in _revoked_tokens
# ---------------------------------------------------------------------------
# User config helpers (persisted JSON)
# ---------------------------------------------------------------------------
_DEFAULT_CONFIG: dict = {
"hostname_display": None, # override for panel display
"timezone": None, # e.g. "America/New_York", None=system
"ntp_enabled": True,
"dock_apps": [
"terminal", "files", "services", "tasks", "network", "settings"
],
"panel_services": [], # systemd unit names to show in panel
"asi_bridge": {
"cifs_share": "//192.168.10.120/share",
"mount_point": "/mnt/asiair",
"cifs_user": "anonymous",
"cifs_pass": "",
},
"session_timeout_minutes": 1440, # 24 h
"stats_interval_seconds": 2,
}
def load_config() -> dict:
"""Load user config from disk, merged over defaults."""
cfg = dict(_DEFAULT_CONFIG)
if USER_CONFIG_FILE.exists():
try:
with open(USER_CONFIG_FILE) as f:
user = json.load(f)
cfg.update(user)
except (json.JSONDecodeError, OSError):
pass
return cfg
def save_config(cfg: dict) -> None:
"""Persist user config to disk."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(USER_CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2)
# ---------------------------------------------------------------------------
# Stats push interval
# ---------------------------------------------------------------------------
STATS_INTERVAL = float(os.environ.get(
"ATLUS_STATS_INTERVAL",
str(load_config().get("stats_interval_seconds", 2)),
))

137
backend/main.py Normal file
View file

@ -0,0 +1,137 @@
"""Atlus — main FastAPI application entry point."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT
from backend.routers import stats, terminal, files, services, processes, settings
from backend.routers.plugins import asi_bridge
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("atlus")
# ---------------------------------------------------------------------------
# Lifespan — start/stop background tasks
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Atlus starting on %s:%d", HOST, PORT)
# Start stats broadcaster
broadcaster = asyncio.create_task(stats.stats_broadcaster())
yield
broadcaster.cancel()
try:
await broadcaster
except asyncio.CancelledError:
pass
log.info("Atlus shutdown complete")
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(
title="Atlus",
description="Web-based desktop environment for headless SBCs",
version="0.1.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# Auth endpoints (not behind auth — they *create* auth)
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/api/auth/login")
async def login(req: LoginRequest):
if not authenticate_user(req.username, req.password):
raise HTTPException(401, "Invalid credentials")
token, jti = create_token(req.username)
return {"token": token, "username": req.username}
@app.post("/api/auth/logout")
async def logout_endpoint(request: Request):
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
logout(auth[7:])
return {"ok": True}
# ---------------------------------------------------------------------------
# API routers
# ---------------------------------------------------------------------------
app.include_router(stats.router)
app.include_router(terminal.router)
app.include_router(files.router)
app.include_router(services.router)
app.include_router(processes.router)
app.include_router(settings.router)
app.include_router(asi_bridge.router)
# ---------------------------------------------------------------------------
# Frontend — served as static files, SPA-style routing
# ---------------------------------------------------------------------------
# Mount static assets (CSS, JS, images)
app.mount("/css", StaticFiles(directory=str(FRONTEND_DIR / "css")), name="css")
app.mount("/js", StaticFiles(directory=str(FRONTEND_DIR / "js")), name="js")
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="assets")
@app.get("/")
async def serve_login():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.get("/desktop")
async def serve_desktop():
return FileResponse(str(FRONTEND_DIR / "desktop.html"))
# ---------------------------------------------------------------------------
# Error handler
# ---------------------------------------------------------------------------
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
log.exception("Unhandled error: %s", exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"backend.main:app",
host=HOST,
port=PORT,
reload=False,
log_level="info",
)

8
backend/requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-pam==2.0.2
PyJWT==2.10.1
psutil==6.1.1
ptyprocess==0.7.0
aiofiles==24.1.0
python-multipart==0.0.20

View file

267
backend/routers/files.py Normal file
View file

@ -0,0 +1,267 @@
"""Filesystem operations — full access, no sandbox."""
import grp
import mimetypes
import os
import pwd
import shutil
import stat
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from backend.auth import get_current_user
router = APIRouter(prefix="/api/files", tags=["files"])
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class FileInfo(BaseModel):
name: str
path: str
is_dir: bool
size: int
modified: float
permissions: str
owner: str
group: str
mime: Optional[str] = None
class RenameRequest(BaseModel):
old_path: str
new_name: str
class MoveRequest(BaseModel):
src: str
dest: str
class MkdirRequest(BaseModel):
path: str
class DeleteRequest(BaseModel):
path: str
class WriteFileRequest(BaseModel):
path: str
content: str
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _file_info(p: Path) -> FileInfo:
try:
st = p.stat()
except OSError:
st = p.lstat()
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
mime = None
if p.is_file():
mime = mimetypes.guess_type(str(p))[0]
return FileInfo(
name=p.name,
path=str(p),
is_dir=p.is_dir(),
size=st.st_size,
modified=st.st_mtime,
permissions=stat.filemode(st.st_mode),
owner=owner,
group=group,
mime=mime,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/list")
async def list_dir(
path: str = Query("/", description="Directory to list"),
_user: str = Depends(get_current_user),
) -> list[FileInfo]:
p = Path(path)
if not p.is_dir():
raise HTTPException(404, f"Not a directory: {path}")
try:
entries = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
except PermissionError:
raise HTTPException(403, f"Permission denied: {path}")
return [_file_info(e) for e in entries]
@router.get("/info")
async def file_info(
path: str = Query(...),
_user: str = Depends(get_current_user),
) -> FileInfo:
p = Path(path)
if not p.exists():
raise HTTPException(404, f"Not found: {path}")
return _file_info(p)
@router.get("/read")
async def read_file(
path: str = Query(...),
_user: str = Depends(get_current_user),
):
"""Read a text file's contents."""
p = Path(path)
if not p.is_file():
raise HTTPException(404, f"Not a file: {path}")
try:
content = p.read_text(errors="replace")
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(p), "content": content}
@router.get("/download")
async def download_file(
path: str = Query(...),
_user: str = Depends(get_current_user),
):
"""Download a file as binary."""
p = Path(path)
if not p.is_file():
raise HTTPException(404, f"Not a file: {path}")
return FileResponse(str(p), filename=p.name)
@router.post("/upload")
async def upload_file(
dest_dir: str = Query(...),
file: UploadFile = File(...),
_user: str = Depends(get_current_user),
):
dest = Path(dest_dir)
if not dest.is_dir():
raise HTTPException(404, f"Not a directory: {dest_dir}")
target = dest / file.filename
try:
with open(target, "wb") as f:
while chunk := await file.read(1024 * 1024):
f.write(chunk)
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(target), "size": target.stat().st_size}
@router.post("/mkdir")
async def mkdir(req: MkdirRequest, _user: str = Depends(get_current_user)):
p = Path(req.path)
try:
p.mkdir(parents=True, exist_ok=True)
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(p)}
@router.post("/rename")
async def rename(req: RenameRequest, _user: str = Depends(get_current_user)):
src = Path(req.old_path)
if not src.exists():
raise HTTPException(404, f"Not found: {req.old_path}")
dest = src.parent / req.new_name
try:
src.rename(dest)
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(dest)}
@router.post("/move")
async def move(req: MoveRequest, _user: str = Depends(get_current_user)):
src = Path(req.src)
dest = Path(req.dest)
if not src.exists():
raise HTTPException(404, f"Not found: {req.src}")
try:
shutil.move(str(src), str(dest))
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(dest)}
@router.post("/copy")
async def copy(req: MoveRequest, _user: str = Depends(get_current_user)):
src = Path(req.src)
dest = Path(req.dest)
if not src.exists():
raise HTTPException(404, f"Not found: {req.src}")
try:
if src.is_dir():
shutil.copytree(str(src), str(dest))
else:
shutil.copy2(str(src), str(dest))
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(dest)}
@router.post("/delete")
async def delete(req: DeleteRequest, _user: str = Depends(get_current_user)):
p = Path(req.path)
if not p.exists():
raise HTTPException(404, f"Not found: {req.path}")
try:
if p.is_dir():
shutil.rmtree(str(p))
else:
p.unlink()
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"deleted": str(p)}
@router.post("/write")
async def write_file(req: WriteFileRequest, _user: str = Depends(get_current_user)):
"""Write/overwrite a text file."""
p = Path(req.path)
try:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(req.content)
except PermissionError:
raise HTTPException(403, "Permission denied")
return {"path": str(p), "size": p.stat().st_size}
@router.get("/mounts")
async def list_mounts(_user: str = Depends(get_current_user)):
"""List mounted filesystems."""
mounts = []
for part in __import__("psutil").disk_partitions(all=False):
usage = __import__("psutil").disk_usage(part.mountpoint)
mounts.append({
"device": part.device,
"mountpoint": part.mountpoint,
"fstype": part.fstype,
"total": usage.total,
"used": usage.used,
"percent": usage.percent,
})
return mounts

View file

View file

@ -0,0 +1,201 @@
"""ASI Bridge plugin — CIFS mount watcher + FITS file scanner."""
import asyncio
import logging
import os
import time
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from backend.auth import get_current_user, ws_authenticate
from backend.config import load_config, save_config
router = APIRouter(prefix="/api/plugins/asi-bridge", tags=["asi-bridge"])
log = logging.getLogger("atlus.asi_bridge")
class MountConfig(BaseModel):
cifs_share: Optional[str] = None
mount_point: Optional[str] = None
cifs_user: Optional[str] = None
cifs_pass: Optional[str] = None
async def _run(cmd: list[str]) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
def _is_mounted(mount_point: str) -> bool:
"""Check if a path is a mount point."""
try:
return os.path.ismount(mount_point)
except OSError:
return False
def _scan_fits(mount_point: str) -> list[dict]:
"""Scan for FITS files in the mount point."""
fits_files = []
mp = Path(mount_point)
if not mp.is_dir():
return fits_files
for root, dirs, files in os.walk(str(mp)):
for fname in files:
if fname.lower().endswith((".fit", ".fits", ".fts")):
fp = Path(root) / fname
try:
st = fp.stat()
fits_files.append({
"name": fname,
"path": str(fp),
"size": st.st_size,
"modified": st.st_mtime,
"relative": str(fp.relative_to(mp)),
})
except OSError:
continue
fits_files.sort(key=lambda x: x["modified"], reverse=True)
return fits_files
@router.get("/status")
async def bridge_status(_user: str = Depends(get_current_user)):
"""Get current ASI Bridge status."""
cfg = load_config().get("asi_bridge", {})
mount_point = cfg.get("mount_point", "/mnt/asiair")
mounted = _is_mounted(mount_point)
fits_count = 0
total_size = 0
latest_file = None
if mounted:
fits = _scan_fits(mount_point)
fits_count = len(fits)
total_size = sum(f["size"] for f in fits)
if fits:
latest_file = fits[0]
return {
"mounted": mounted,
"mount_point": mount_point,
"cifs_share": cfg.get("cifs_share", ""),
"fits_count": fits_count,
"total_size": total_size,
"latest_file": latest_file,
}
@router.get("/files")
async def list_fits(_user: str = Depends(get_current_user)):
"""List all FITS files on the mount."""
cfg = load_config().get("asi_bridge", {})
mount_point = cfg.get("mount_point", "/mnt/asiair")
if not _is_mounted(mount_point):
raise HTTPException(503, "ASI Air share not mounted")
return _scan_fits(mount_point)
@router.post("/mount")
async def mount_share(_user: str = Depends(get_current_user)):
"""Mount the ASI Air CIFS share."""
cfg = load_config().get("asi_bridge", {})
mount_point = cfg.get("mount_point", "/mnt/asiair")
share = cfg.get("cifs_share", "")
user = cfg.get("cifs_user", "anonymous")
password = cfg.get("cifs_pass", "")
if not share:
raise HTTPException(400, "CIFS share not configured")
Path(mount_point).mkdir(parents=True, exist_ok=True)
creds = f"username={user}"
if password:
creds += f",password={password}"
rc, out, err = await _run([
"mount", "-t", "cifs", share, mount_point,
"-o", f"{creds},iocharset=utf8,vers=3.0",
])
if rc != 0:
raise HTTPException(500, f"Mount failed: {err}")
return {"mounted": True, "mount_point": mount_point}
@router.post("/unmount")
async def unmount_share(_user: str = Depends(get_current_user)):
cfg = load_config().get("asi_bridge", {})
mount_point = cfg.get("mount_point", "/mnt/asiair")
rc, out, err = await _run(["umount", mount_point])
if rc != 0:
raise HTTPException(500, f"Unmount failed: {err}")
return {"mounted": False, "mount_point": mount_point}
@router.get("/config")
async def get_bridge_config(_user: str = Depends(get_current_user)):
cfg = load_config()
return cfg.get("asi_bridge", {})
@router.put("/config")
async def update_bridge_config(update: MountConfig, _user: str = Depends(get_current_user)):
cfg = load_config()
bridge = cfg.get("asi_bridge", {})
for field, value in update.model_dump(exclude_none=True).items():
bridge[field] = value
cfg["asi_bridge"] = bridge
save_config(cfg)
return bridge
@router.websocket("/ws")
async def bridge_ws(websocket: WebSocket):
"""Stream mount status and new FITS file events."""
username = await ws_authenticate(websocket)
await websocket.accept()
cfg = load_config().get("asi_bridge", {})
mount_point = cfg.get("mount_point", "/mnt/asiair")
seen_files: set[str] = set()
last_mounted = None
try:
while True:
mounted = _is_mounted(mount_point)
if mounted != last_mounted:
await websocket.send_json({
"type": "mount_status",
"mounted": mounted,
})
last_mounted = mounted
if mounted:
fits = _scan_fits(mount_point)
current_paths = {f["path"] for f in fits}
new_files = current_paths - seen_files
if new_files and seen_files:
for f in fits:
if f["path"] in new_files:
await websocket.send_json({
"type": "new_file",
"file": f,
})
seen_files = current_paths
await asyncio.sleep(5)
except WebSocketDisconnect:
pass
except Exception:
log.exception("ASI Bridge WS error")

View file

@ -0,0 +1,89 @@
"""Process list and signal management."""
import os
import signal
import psutil
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.auth import get_current_user
router = APIRouter(prefix="/api/processes", tags=["processes"])
class SignalRequest(BaseModel):
pid: int
signal: str = "SIGTERM" # SIGTERM, SIGKILL, SIGSTOP, SIGCONT, SIGHUP
@router.get("")
async def list_processes(_user: str = Depends(get_current_user)):
"""List all running processes."""
procs = []
for p in psutil.process_iter(["pid", "name", "username", "cpu_percent",
"memory_percent", "status", "create_time",
"cmdline"]):
try:
info = p.info
procs.append({
"pid": info["pid"],
"name": info["name"],
"user": info["username"],
"cpu": info["cpu_percent"],
"mem": round(info["memory_percent"], 1) if info["memory_percent"] else 0,
"status": info["status"],
"started": info["create_time"],
"cmdline": " ".join(info["cmdline"][:5]) if info["cmdline"] else "",
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return procs
@router.get("/{pid}")
async def process_detail(pid: int, _user: str = Depends(get_current_user)):
try:
p = psutil.Process(pid)
with p.oneshot():
return {
"pid": p.pid,
"name": p.name(),
"user": p.username(),
"cpu": p.cpu_percent(),
"mem": round(p.memory_percent(), 1),
"status": p.status(),
"started": p.create_time(),
"cmdline": p.cmdline(),
"cwd": p.cwd(),
"num_threads": p.num_threads(),
"open_files": len(p.open_files()),
"connections": len(p.net_connections()),
"memory_info": {
"rss": p.memory_info().rss,
"vms": p.memory_info().vms,
},
}
except psutil.NoSuchProcess:
raise HTTPException(404, f"Process {pid} not found")
except psutil.AccessDenied:
raise HTTPException(403, f"Access denied to process {pid}")
@router.post("/signal")
async def send_signal(req: SignalRequest, _user: str = Depends(get_current_user)):
sig_name = req.signal.upper()
if not sig_name.startswith("SIG"):
sig_name = "SIG" + sig_name
try:
sig = getattr(signal, sig_name)
except AttributeError:
raise HTTPException(400, f"Unknown signal: {req.signal}")
try:
os.kill(req.pid, sig)
except ProcessLookupError:
raise HTTPException(404, f"Process {req.pid} not found")
except PermissionError:
raise HTTPException(403, f"Permission denied for pid {req.pid}")
return {"pid": req.pid, "signal": sig_name, "sent": True}

131
backend/routers/services.py Normal file
View file

@ -0,0 +1,131 @@
"""Systemd service management wrapper."""
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from backend.auth import get_current_user, ws_authenticate
router = APIRouter(prefix="/api/services", tags=["services"])
log = logging.getLogger("atlus.services")
class ServiceAction(BaseModel):
unit: str
action: str # start, stop, restart, enable, disable
async def _run(cmd: list[str]) -> tuple[int, str, str]:
import shutil
if not shutil.which(cmd[0]):
return 1, "", f"{cmd[0]}: command not found"
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
async def _get_services() -> list[dict]:
"""List all loaded systemd services."""
rc, out, _ = await _run([
"systemctl", "list-units", "--type=service", "--all",
"--no-pager", "--no-legend", "--plain",
])
services = []
for line in out.strip().splitlines():
parts = line.split(None, 4)
if len(parts) < 4:
continue
unit = parts[0]
if not unit.endswith(".service"):
continue
services.append({
"unit": unit,
"name": unit.removesuffix(".service"),
"load": parts[1],
"active": parts[2],
"sub": parts[3],
"description": parts[4] if len(parts) > 4 else "",
})
return services
async def _get_service_status(unit: str) -> dict:
"""Get detailed status for a single service."""
rc, out, _ = await _run(["systemctl", "show", unit, "--no-pager"])
props = {}
for line in out.strip().splitlines():
if "=" in line:
k, _, v = line.partition("=")
props[k] = v
return {
"unit": unit,
"name": unit.removesuffix(".service"),
"active": props.get("ActiveState", "unknown"),
"sub": props.get("SubState", "unknown"),
"enabled": props.get("UnitFileState", "unknown"),
"description": props.get("Description", ""),
"main_pid": int(props.get("MainPID", 0)),
"memory": props.get("MemoryCurrent", "0"),
"started": props.get("ActiveEnterTimestamp", ""),
}
@router.get("")
async def list_services(_user: str = Depends(get_current_user)):
return await _get_services()
@router.get("/{unit}")
async def service_status(unit: str, _user: str = Depends(get_current_user)):
return await _get_service_status(unit)
@router.post("/action")
async def service_action(req: ServiceAction, _user: str = Depends(get_current_user)):
if req.action not in ("start", "stop", "restart", "enable", "disable"):
raise HTTPException(400, f"Invalid action: {req.action}")
rc, out, err = await _run(["systemctl", req.action, req.unit])
if rc != 0:
raise HTTPException(500, f"systemctl {req.action} {req.unit} failed: {err}")
return {
"unit": req.unit,
"action": req.action,
"status": await _get_service_status(req.unit),
}
@router.websocket("/{unit}/logs")
async def service_logs(websocket: WebSocket, unit: str):
"""Stream journalctl -f output for a service."""
username = await ws_authenticate(websocket)
await websocket.accept()
proc = await asyncio.create_subprocess_exec(
"journalctl", "-u", unit, "-f", "-n", "100", "--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
while True:
line = await proc.stdout.readline()
if not line:
break
await websocket.send_json({
"type": "log",
"unit": unit,
"line": line.decode(errors="replace").rstrip(),
})
except WebSocketDisconnect:
pass
except Exception:
log.exception("log stream error for %s", unit)
finally:
proc.kill()
await proc.wait()

116
backend/routers/settings.py Normal file
View file

@ -0,0 +1,116 @@
"""Settings — config read/write, hostname, timezone management."""
import asyncio
import platform
import socket
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.auth import get_current_user
from backend.config import load_config, save_config
router = APIRouter(prefix="/api/settings", tags=["settings"])
class ConfigUpdate(BaseModel):
"""Partial config update — only send fields to change."""
hostname_display: Optional[str] = None
timezone: Optional[str] = None
ntp_enabled: Optional[bool] = None
dock_apps: Optional[list[str]] = None
panel_services: Optional[list[str]] = None
session_timeout_minutes: Optional[int] = None
stats_interval_seconds: Optional[int] = None
class HostnameRequest(BaseModel):
hostname: str
class TimezoneRequest(BaseModel):
timezone: str
async def _run(cmd: list[str]) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
@router.get("")
async def get_config(_user: str = Depends(get_current_user)):
return load_config()
@router.put("")
async def update_config(update: ConfigUpdate, _user: str = Depends(get_current_user)):
cfg = load_config()
for field, value in update.model_dump(exclude_none=True).items():
cfg[field] = value
save_config(cfg)
return cfg
@router.get("/system")
async def system_info(_user: str = Depends(get_current_user)):
"""System info for the About section."""
hostname = socket.gethostname()
uname = platform.uname()
# OS release info
os_name = "Linux"
os_version = ""
try:
with open("/etc/os-release") as f:
for line in f:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip().strip('"')
elif line.startswith("VERSION="):
os_version = line.split("=", 1)[1].strip().strip('"')
except FileNotFoundError:
pass
return {
"hostname": hostname,
"os": os_name,
"os_version": os_version,
"kernel": uname.release,
"arch": uname.machine,
"python": platform.python_version(),
}
@router.post("/hostname")
async def set_hostname(req: HostnameRequest, _user: str = Depends(get_current_user)):
rc, _, err = await _run(["hostnamectl", "set-hostname", req.hostname])
if rc != 0:
raise HTTPException(500, f"Failed to set hostname: {err}")
return {"hostname": req.hostname}
@router.get("/timezone")
async def get_timezone(_user: str = Depends(get_current_user)):
rc, out, _ = await _run(["timedatectl", "show", "--property=Timezone", "--value"])
return {"timezone": out.strip() if rc == 0 else "unknown"}
@router.post("/timezone")
async def set_timezone(req: TimezoneRequest, _user: str = Depends(get_current_user)):
rc, _, err = await _run(["timedatectl", "set-timezone", req.timezone])
if rc != 0:
raise HTTPException(500, f"Failed to set timezone: {err}")
return {"timezone": req.timezone}
@router.get("/timezones")
async def list_timezones(_user: str = Depends(get_current_user)):
rc, out, _ = await _run(["timedatectl", "list-timezones"])
if rc != 0:
return []
return out.strip().splitlines()

121
backend/routers/stats.py Normal file
View file

@ -0,0 +1,121 @@
"""System stats endpoint and background WebSocket broadcaster."""
import asyncio
import logging
import time
import psutil
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from backend.auth import get_current_user, ws_authenticate
from backend.config import STATS_INTERVAL
from backend.ws.manager import manager
router = APIRouter(prefix="/api/stats", tags=["stats"])
log = logging.getLogger("atlus.stats")
def _gather_stats() -> dict:
"""Collect current system stats via psutil."""
cpu_percent = psutil.cpu_percent(interval=0)
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
# CPU temperature — varies by platform
temps = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
cpu_temp = None
for name in ("cpu_thermal", "coretemp", "cpu-thermal", "soc_thermal"):
if name in temps and temps[name]:
cpu_temp = temps[name][0].current
break
if cpu_temp is None and temps:
first = next(iter(temps.values()))
if first:
cpu_temp = first[0].current
# Network interfaces — filter out virtual/tunnel/loopback
_SKIP_PREFIXES = ("lo", "utun", "anpi", "bridge", "awdl", "llw", "gif", "stf", "ap", "veth", "docker", "br-")
addrs = psutil.net_if_addrs()
net_stats = psutil.net_if_stats()
interfaces = {}
for iface, addr_list in addrs.items():
if any(iface.startswith(p) for p in _SKIP_PREFIXES):
continue
info: dict = {"up": net_stats.get(iface, None) is not None and net_stats[iface].isup}
for a in addr_list:
if a.family.name == "AF_INET":
info["ipv4"] = a.address
elif a.family.name == "AF_INET6" and not a.address.startswith("fe80"):
info["ipv6"] = a.address
interfaces[iface] = info
# Network I/O
net_io = psutil.net_io_counters()
return {
"ts": time.time(),
"cpu_percent": cpu_percent,
"memory": {
"total": mem.total,
"used": mem.used,
"percent": mem.percent,
},
"disk": {
"total": disk.total,
"used": disk.used,
"percent": disk.percent,
},
"cpu_temp": cpu_temp,
"network": {
"interfaces": interfaces,
"bytes_sent": net_io.bytes_sent,
"bytes_recv": net_io.bytes_recv,
},
"uptime": time.time() - psutil.boot_time(),
"load_avg": list(psutil.getloadavg()) if hasattr(psutil, "getloadavg") else None,
}
# ---------------------------------------------------------------------------
# REST endpoint — one-shot stats
# ---------------------------------------------------------------------------
@router.get("")
async def get_stats(_user: str = Depends(get_current_user)):
return _gather_stats()
# ---------------------------------------------------------------------------
# WebSocket — live push
# ---------------------------------------------------------------------------
@router.websocket("/ws")
async def stats_ws(websocket: WebSocket):
username = await ws_authenticate(websocket)
await manager.connect(websocket, channel="stats")
try:
while True:
data = _gather_stats()
await manager.send_personal(websocket, data)
await asyncio.sleep(STATS_INTERVAL)
except (WebSocketDisconnect, RuntimeError):
pass
except Exception:
log.exception("stats ws error")
finally:
manager.disconnect(websocket, channel="stats")
# ---------------------------------------------------------------------------
# Background broadcaster (called from main.py lifespan)
# ---------------------------------------------------------------------------
async def stats_broadcaster():
"""Push stats to all connected clients on the 'stats' channel."""
while True:
try:
data = _gather_stats()
await manager.broadcast(data, channel="stats")
except Exception:
log.exception("broadcast error")
await asyncio.sleep(STATS_INTERVAL)

116
backend/routers/terminal.py Normal file
View file

@ -0,0 +1,116 @@
"""Terminal PTY via WebSocket — one PTY per connection."""
import asyncio
import logging
import os
import pwd
import signal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ptyprocess import PtyProcess
from backend.auth import ws_authenticate
router = APIRouter(tags=["terminal"])
log = logging.getLogger("atlus.terminal")
# Track active PTYs for cleanup
_active_ptys: dict[int, PtyProcess] = {}
def _spawn_pty(username: str, cols: int = 120, rows: int = 30) -> PtyProcess:
"""Spawn a login shell for the given user."""
try:
pw = pwd.getpwnam(username)
except KeyError:
pw = None
shell = pw.pw_shell if pw else "/bin/bash"
home = pw.pw_dir if pw else "/"
uid = pw.pw_uid if pw else 0
gid = pw.pw_gid if pw else 0
env = {
"TERM": "xterm-256color",
"HOME": home,
"USER": username,
"SHELL": shell,
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
pty = PtyProcess.spawn(
[shell, "-l"],
dimensions=(rows, cols),
env=env,
cwd=home,
)
# If running as root, setuid to the target user
# (PtyProcess doesn't do this automatically)
# Note: the child already inherits from spawn, but we track it
_active_ptys[pty.pid] = pty
return pty
@router.websocket("/api/terminal/ws")
async def terminal_ws(websocket: WebSocket):
"""Full-duplex PTY session over WebSocket.
Client sends:
- {"type": "input", "data": "..."} keystrokes
- {"type": "resize", "cols": N, "rows": N}
Server sends:
- {"type": "output", "data": "..."} terminal output
"""
username = await ws_authenticate(websocket)
await websocket.accept()
pty = _spawn_pty(username)
fd = pty.fd
loop = asyncio.get_event_loop()
async def read_pty():
"""Read from PTY fd and push to WebSocket."""
while pty.isalive():
try:
raw = await loop.run_in_executor(None, lambda: pty.read(4096))
if raw:
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
await websocket.send_json({"type": "output", "data": text})
except EOFError:
break
except Exception:
break
reader_task = asyncio.create_task(read_pty())
try:
while True:
msg = await websocket.receive_json()
msg_type = msg.get("type")
if msg_type == "input":
data = msg["data"]
if isinstance(data, str):
data = data.encode("utf-8")
pty.write(data)
elif msg_type == "resize":
cols = msg.get("cols", 120)
rows = msg.get("rows", 30)
pty.setwinsize(rows, cols)
except WebSocketDisconnect:
pass
except Exception:
log.exception("terminal ws error")
finally:
reader_task.cancel()
if pty.isalive():
try:
pty.kill(signal.SIGHUP)
pty.wait()
except Exception:
pass
_active_ptys.pop(pty.pid, None)
log.info("Terminal session ended for %s (pid %d)", username, pty.pid)

0
backend/ws/__init__.py Normal file
View file

55
backend/ws/manager.py Normal file
View file

@ -0,0 +1,55 @@
"""WebSocket connection manager — multi-client broadcast for Atlus."""
import asyncio
import json
import logging
from typing import Any
from fastapi import WebSocket
log = logging.getLogger("atlus.ws")
class ConnectionManager:
"""Track active WebSocket connections and broadcast messages."""
def __init__(self) -> None:
self._connections: dict[str, set[WebSocket]] = {} # channel -> sockets
async def connect(self, websocket: WebSocket, channel: str = "stats") -> None:
await websocket.accept()
self._connections.setdefault(channel, set()).add(websocket)
log.info("WS connect: channel=%s total=%d", channel, len(self._connections[channel]))
def disconnect(self, websocket: WebSocket, channel: str = "stats") -> None:
sockets = self._connections.get(channel)
if sockets:
sockets.discard(websocket)
log.info("WS disconnect: channel=%s total=%d", channel, len(sockets))
async def broadcast(self, data: Any, channel: str = "stats") -> None:
"""Send JSON data to all clients on a channel."""
sockets = self._connections.get(channel)
if not sockets:
return
payload = json.dumps(data) if not isinstance(data, str) else data
stale: list[WebSocket] = []
for ws in sockets:
try:
await ws.send_text(payload)
except Exception:
stale.append(ws)
for ws in stale:
sockets.discard(ws)
async def send_personal(self, websocket: WebSocket, data: Any) -> None:
payload = json.dumps(data) if not isinstance(data, str) else data
await websocket.send_text(payload)
@property
def active_count(self) -> int:
return sum(len(s) for s in self._connections.values())
# Singleton shared across the app
manager = ConnectionManager()

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0d0f14"/>
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle"
font-family="'IBM Plex Mono', monospace" font-weight="500" font-size="36" fill="#6ea6f0">A</text>
</svg>

After

Width:  |  Height:  |  Size: 314 B

254
frontend/css/apps/files.css Normal file
View file

@ -0,0 +1,254 @@
/* File Manager app styles */
.app-files {
display: flex;
flex-direction: column;
height: 100%;
}
.files-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-structural);
min-height: 44px;
}
.files-breadcrumb {
flex: 1;
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.breadcrumb-segment {
padding: 4px 6px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.breadcrumb-segment:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
.breadcrumb-segment:last-child {
color: var(--text-primary);
}
.breadcrumb-sep {
color: var(--text-muted);
font-size: 12px;
}
.files-action-btn {
width: 36px;
height: 36px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.files-action-btn:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
/* File body — dual pane */
.files-body {
flex: 1;
display: flex;
overflow: hidden;
}
.files-sidebar {
width: 180px;
min-width: 160px;
background: var(--bg-dock);
border-right: 1px solid var(--border-structural);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8px 0;
}
.sidebar-heading {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 1px;
padding: 8px 12px 4px;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
min-height: var(--tap-min);
background: none;
border: none;
width: 100%;
text-align: left;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
border-radius: 0;
}
.sidebar-item:hover {
background: var(--accent-hover);
}
.sidebar-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-icon {
font-size: 14px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
/* File list panel */
.files-list-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.files-list-header {
display: grid;
grid-template-columns: 1fr 80px 140px 90px;
padding: 0 12px;
height: 32px;
align-items: center;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-structural);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.files-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.file-row {
display: grid;
grid-template-columns: 1fr 80px 140px 90px;
padding: 0 12px;
min-height: 48px;
align-items: center;
border-bottom: 1px solid var(--border-structural);
cursor: pointer;
transition: background var(--transition-fast);
}
.file-row:hover {
background: var(--accent-hover);
}
.file-row.selected {
background: var(--accent-dim);
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
}
.file-name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-icon {
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
color: var(--text-secondary);
}
.file-icon.dir { color: var(--accent); }
.file-size,
.file-modified,
.file-perms {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
/* Context menu */
.file-context-menu {
position: fixed;
z-index: 500;
background: var(--bg-titlebar);
border: 1px solid var(--border-structural);
border-radius: var(--radius-md);
padding: 4px;
min-width: 160px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.context-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
min-height: 40px;
background: none;
border: none;
width: 100%;
text-align: left;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 13px;
cursor: pointer;
border-radius: var(--radius-sm);
}
.context-item:hover {
background: var(--accent-hover);
}
.context-item.danger {
color: var(--status-red);
}
.context-sep {
height: 1px;
background: var(--border-structural);
margin: 4px 8px;
}

View file

@ -0,0 +1,151 @@
/* Service Manager app styles */
.app-services {
display: flex;
flex-direction: column;
height: 100%;
}
.services-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-structural);
min-height: 44px;
}
.services-search {
flex: 1;
height: 36px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-structural);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
}
.services-search:focus {
border-color: var(--accent);
}
.services-search::placeholder {
color: var(--text-muted);
}
.services-filter-btn {
height: 36px;
padding: 0 12px;
background: var(--bg-stage);
border: 1px solid var(--border-structural);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: 12px;
cursor: pointer;
}
.services-filter-btn.active {
background: var(--accent-dim);
color: var(--accent);
border-color: var(--accent);
}
.services-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.services-header {
display: grid;
grid-template-columns: 52px 1fr 80px 80px 60px;
padding: 0 16px;
height: 32px;
align-items: center;
background: var(--bg-dock);
border-bottom: 1px solid var(--border-structural);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.5px;
position: sticky;
top: 0;
z-index: 1;
}
.service-row {
display: grid;
grid-template-columns: 52px 1fr 80px 80px 60px;
padding: 0 16px;
min-height: 52px;
align-items: center;
border-bottom: 1px solid var(--border-structural);
}
.svc-toggle {
justify-self: center;
}
.svc-name {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.svc-desc {
font-family: var(--font-ui);
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.svc-state {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
}
.svc-state.active { color: var(--status-green); }
.svc-state.inactive { color: var(--text-muted); }
.svc-state.failed { color: var(--status-red); }
.svc-sub {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.svc-actions {
display: flex;
gap: 4px;
}
.svc-action-btn {
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.svc-action-btn:hover {
background: var(--accent-hover);
color: var(--accent);
}

View file

@ -0,0 +1,183 @@
/* Settings app styles */
.app-settings {
display: flex;
height: 100%;
}
.settings-nav {
width: 180px;
min-width: 160px;
background: var(--bg-dock);
border-right: 1px solid var(--border-structural);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8px 0;
}
.settings-nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
min-height: var(--tap-min);
background: none;
border: none;
width: 100%;
text-align: left;
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: 13px;
cursor: pointer;
}
.settings-nav-item:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
.settings-nav-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.settings-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 24px;
}
.settings-section-title {
font-family: var(--font-ui);
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 24px;
}
.settings-group {
margin-bottom: 32px;
}
.settings-group-title {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 1.5px;
margin-bottom: 12px;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
padding: 8px 0;
border-bottom: 1px solid var(--border-structural);
gap: 16px;
}
.settings-row-label {
font-family: var(--font-ui);
font-size: 14px;
color: var(--text-primary);
}
.settings-row-desc {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.settings-input {
height: 40px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-structural);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
min-width: 200px;
}
.settings-input:focus {
border-color: var(--accent);
}
.settings-select {
height: 40px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-structural);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
min-width: 200px;
cursor: pointer;
}
.settings-toggle {
position: relative;
width: 51px;
height: 31px;
background: var(--border-component);
border-radius: 16px;
border: none;
cursor: pointer;
transition: background var(--transition-fast);
flex-shrink: 0;
}
.settings-toggle.on {
background: var(--status-green);
}
.settings-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 27px;
height: 27px;
border-radius: 50%;
background: #fff;
transition: transform var(--transition-fast);
}
.settings-toggle.on::after {
transform: translateX(20px);
}
.settings-btn {
height: 40px;
padding: 0 20px;
background: var(--accent);
border: none;
border-radius: var(--radius-md);
color: #fff;
font-family: var(--font-ui);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.settings-btn:hover {
opacity: 0.9;
}
.settings-btn.secondary {
background: var(--bg-titlebar);
border: 1px solid var(--border-structural);
color: var(--text-primary);
}
.settings-actions {
display: flex;
gap: 8px;
margin-top: 24px;
}

140
frontend/css/apps/tasks.css Normal file
View file

@ -0,0 +1,140 @@
/* Task Manager app styles */
.app-tasks {
display: flex;
flex-direction: column;
height: 100%;
}
.tasks-summary {
display: flex;
gap: 16px;
padding: 12px 16px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-structural);
flex-wrap: wrap;
}
.tasks-summary-item {
display: flex;
align-items: baseline;
gap: 6px;
}
.tasks-summary-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 1px;
}
.tasks-summary-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.tasks-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-structural);
min-height: 44px;
}
.tasks-search {
flex: 1;
height: 36px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-structural);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
}
.tasks-search:focus {
border-color: var(--accent);
}
.tasks-search::placeholder {
color: var(--text-muted);
}
.tasks-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.tasks-header {
display: grid;
grid-template-columns: 70px 1fr 90px 70px 70px 70px 50px;
padding: 0 16px;
height: 32px;
align-items: center;
background: var(--bg-dock);
border-bottom: 1px solid var(--border-structural);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.5px;
position: sticky;
top: 0;
z-index: 1;
cursor: pointer;
}
.tasks-header span:hover {
color: var(--text-secondary);
}
.tasks-header span.sorted {
color: var(--accent);
}
.proc-row {
display: grid;
grid-template-columns: 70px 1fr 90px 70px 70px 70px 50px;
padding: 0 16px;
min-height: 44px;
align-items: center;
border-bottom: 1px solid var(--border-structural);
font-family: var(--font-mono);
font-size: 12px;
}
.proc-row:hover {
background: var(--accent-hover);
}
.proc-pid { color: var(--text-secondary); }
.proc-name { color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proc-user { color: var(--text-secondary); }
.proc-cpu { color: var(--text-primary); text-align: right; }
.proc-mem { color: var(--text-primary); text-align: right; }
.proc-status { color: var(--text-secondary); font-size: 11px; }
.proc-kill {
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.proc-kill:hover {
background: rgba(224, 90, 74, 0.15);
color: var(--status-red);
}

View file

@ -0,0 +1,93 @@
/* Terminal app styles */
.app-terminal {
display: flex;
flex-direction: column;
height: 100%;
}
.terminal-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-structural);
min-height: 36px;
}
.terminal-tabs {
display: flex;
gap: 2px;
flex: 1;
overflow-x: auto;
}
.terminal-tab {
height: 28px;
padding: 0 10px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.terminal-tab.active {
background: var(--bg-stage);
color: var(--text-primary);
}
.terminal-tab-close {
font-size: 10px;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
border-radius: 50%;
}
.terminal-tab-close:hover {
background: var(--status-red);
color: #fff;
}
.terminal-new-tab,
.terminal-kbd-toggle {
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.terminal-new-tab:hover,
.terminal-kbd-toggle:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
.terminal-kbd-toggle.active {
color: var(--accent);
}
.terminal-container {
flex: 1;
position: relative;
overflow: hidden;
}
.terminal-container .xterm {
height: 100%;
padding: 4px;
}

115
frontend/css/dock.css Normal file
View file

@ -0,0 +1,115 @@
/* Left Dock — 72px vertical app launcher */
.dock {
width: var(--dock-width);
min-width: var(--dock-width);
height: 100dvh;
background: var(--bg-dock);
border-right: 1px solid var(--border-structural);
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.dock-top,
.dock-bottom {
flex-shrink: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.dock-apps {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 0;
overflow-y: auto;
}
.dock-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 64px;
min-height: 56px;
padding: 6px 4px;
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
position: relative;
}
.dock-item:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
.dock-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.dock-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.dock-icon {
font-size: 20px;
line-height: 1;
margin-bottom: 2px;
}
.dock-logo .dock-icon {
font-family: var(--font-mono);
font-weight: 500;
font-size: 24px;
color: var(--accent);
}
.dock-label {
font-family: var(--font-ui);
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
color: inherit;
opacity: 0.8;
}
.dock-separator {
width: 36px;
height: 1px;
background: var(--border-structural);
margin: 8px 0;
}
/* Badge dot */
.dock-item .badge {
position: absolute;
top: 6px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}

138
frontend/css/keyboard.css Normal file
View file

@ -0,0 +1,138 @@
/* Custom on-screen keyboard for terminal */
.osk {
background: var(--bg-dock);
border-top: 1px solid var(--border-structural);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.osk.hidden { display: none; }
/* Quick row — frequently used symbols */
.osk-quick {
display: flex;
gap: 3px;
padding: 2px 0;
}
/* Mode tabs */
.osk-modes {
display: flex;
gap: 2px;
margin-bottom: 2px;
}
.osk-mode-tab {
flex: 1;
height: 32px;
background: var(--bg-stage);
border: 1px solid var(--border-structural);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: 11px;
font-weight: 500;
cursor: pointer;
}
.osk-mode-tab.active {
background: var(--accent-dim);
color: var(--accent);
border-color: var(--accent);
}
/* Keyboard rows */
.osk-row {
display: flex;
gap: 3px;
justify-content: center;
}
/* Keys */
.osk-key {
min-width: 36px;
height: 40px;
padding: 0 6px;
background: var(--bg-titlebar);
border: 1px solid var(--border-component);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition-fast);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.osk-key:active {
background: var(--accent-dim);
}
/* Modifier keys */
.osk-key.mod {
background: var(--bg-stage);
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-ui);
font-weight: 500;
}
.osk-key.mod.armed {
background: var(--accent-dim);
color: var(--accent);
border-color: var(--accent);
}
.osk-key.mod.locked {
background: var(--accent);
color: #fff;
}
/* Special width keys */
.osk-key.wide { flex: 1.5; }
.osk-key.wider { flex: 2; }
.osk-key.space { flex: 5; }
/* Quick row keys — smaller */
.osk-quick .osk-key {
min-width: 32px;
height: 32px;
font-size: 14px;
flex: 1;
}
/* Fn keys — horizontally scrollable */
.osk-fn-scroll {
display: flex;
gap: 3px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.osk-fn-scroll .osk-key {
min-width: 44px;
flex-shrink: 0;
font-size: 11px;
}
/* Nav grid */
.osk-nav {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 3px;
}
.osk-nav .osk-key {
min-width: unset;
font-size: 12px;
}

214
frontend/css/panel.css Normal file
View file

@ -0,0 +1,214 @@
/* Right Panel — 240px system info + services */
.panel {
width: var(--panel-width);
min-width: var(--panel-width);
height: 100dvh;
background: var(--bg-panel);
border-left: 1px solid var(--border-structural);
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.panel-section {
padding: 16px;
border-bottom: 1px solid var(--border-structural);
}
.panel-section:last-child {
border-bottom: none;
flex: 1;
}
.panel-section-title {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 1.5px;
margin-bottom: 12px;
}
/* Info section */
.panel-hostname {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
.panel-datetime {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.panel-date {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.panel-time {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
}
/* Network */
.panel-net-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-family: var(--font-mono);
font-size: 12px;
}
.panel-net-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.panel-net-dot.up { background: var(--status-green); }
.panel-net-dot.down { background: var(--status-red); }
.panel-net-name {
color: var(--text-primary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-net-ip {
color: var(--text-secondary);
font-size: 11px;
}
/* Stats */
.stat-row {
display: grid;
grid-template-columns: 40px 1fr;
grid-template-rows: auto auto;
gap: 2px 8px;
margin-bottom: 12px;
align-items: center;
}
.stat-label {
grid-row: 1 / 3;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 1px;
}
.stat-value {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
text-align: right;
}
.stat-bar {
height: 4px;
background: var(--border-structural);
border-radius: 2px;
overflow: hidden;
}
.stat-bar-fill {
height: 100%;
border-radius: 2px;
background: var(--status-green);
transition: width var(--transition-base), background-color var(--transition-base);
width: 0%;
}
.stat-bar-fill.warn { background: var(--status-amber); }
.stat-bar-fill.crit { background: var(--status-red); }
/* Services */
.panel-service-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.panel-service-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 6px 0;
}
.service-toggle {
position: relative;
width: 36px;
height: 20px;
background: var(--border-component);
border-radius: 10px;
border: none;
cursor: pointer;
transition: background var(--transition-fast);
flex-shrink: 0;
}
.service-toggle.on {
background: var(--status-green);
}
.service-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform var(--transition-fast);
}
.service-toggle.on::after {
transform: translateX(16px);
}
.service-name {
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-open {
width: 28px;
height: 28px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.service-open:hover {
background: var(--accent-hover);
color: var(--accent);
}

143
frontend/css/shell.css Normal file
View file

@ -0,0 +1,143 @@
/* Atlus Shell — three-column layout */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100dvh;
overflow: hidden;
font-family: var(--font-ui);
background: var(--bg-stage);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
}
body {
display: flex;
flex-direction: row;
}
/* Utility */
.hidden { display: none !important; }
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-component);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* System menu overlay */
.system-menu {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.system-menu-content {
background: var(--bg-titlebar);
border: 1px solid var(--border-structural);
border-radius: var(--radius-lg);
padding: 8px;
min-width: 200px;
}
.system-menu-item {
display: block;
width: 100%;
padding: 12px 16px;
background: none;
border: none;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 14px;
text-align: left;
border-radius: var(--radius-sm);
cursor: pointer;
min-height: var(--tap-min);
display: flex;
align-items: center;
}
.system-menu-item:hover {
background: var(--accent-hover);
}
.system-menu-danger {
color: var(--status-red);
}
.system-menu-sep {
height: 1px;
background: var(--border-structural);
margin: 4px 8px;
}
.system-menu-about {
padding: 8px 16px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
text-align: center;
}
/* Welcome screen */
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
.welcome-logo {
font-family: var(--font-mono);
font-size: 72px;
font-weight: 500;
color: var(--text-ghost);
}
.welcome-text {
font-size: 14px;
color: var(--text-muted);
}
/* Responsive — portrait/narrow */
@media (max-width: 768px) {
.dock {
position: fixed;
left: -var(--dock-width);
z-index: 100;
transition: transform var(--transition-base);
}
.dock.open {
transform: translateX(var(--dock-width));
}
.panel {
position: fixed;
right: calc(-1 * var(--panel-width));
z-index: 100;
transition: transform var(--transition-base);
}
.panel.open {
transform: translateX(calc(-1 * var(--panel-width)));
}
.stage {
width: 100%;
}
}

179
frontend/css/stage.css Normal file
View file

@ -0,0 +1,179 @@
/* Center Stage — tabbed app area */
.stage {
flex: 1;
height: 100dvh;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* Tab bar */
.stage-tabbar {
height: var(--tab-height);
min-height: var(--tab-height);
background: var(--bg-dock);
border-bottom: 1px solid var(--border-structural);
display: flex;
align-items: center;
padding: 0 8px;
gap: 4px;
}
.stage-tabs {
flex: 1;
display: flex;
gap: 2px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.stage-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
height: 32px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), color var(--transition-fast);
min-height: 32px;
}
.stage-tab:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
.stage-tab.active {
background: var(--bg-stage);
color: var(--text-primary);
}
.stage-tab .tab-close {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
line-height: 1;
}
.stage-tab .tab-close:hover {
background: var(--status-red);
color: #fff;
}
/* Layout controls */
.stage-controls {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.layout-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.layout-btn:hover {
background: var(--accent-hover);
color: var(--text-primary);
}
.layout-btn.active {
color: var(--accent);
}
/* Panes */
.stage-panes {
flex: 1;
display: flex;
overflow: hidden;
}
.stage-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.stage-pane + .stage-pane {
border-left: 1px solid var(--border-structural);
}
/* Titlebar */
.pane-titlebar {
height: var(--titlebar-height);
min-height: var(--titlebar-height);
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-structural);
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
}
.pane-dots {
display: flex;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot-red { background: var(--status-red); }
.dot-amber { background: var(--status-amber); }
.dot-green { background: var(--status-green); }
.pane-title {
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Pane content */
.pane-content {
flex: 1;
overflow: auto;
-webkit-overflow-scrolling: touch;
position: relative;
}
/* App container */
.app-view {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,51 @@
/* Atlus Design Tokens */
:root {
/* Background layers (dark → light) */
--bg-dock: #0d0f14;
--bg-panel: #0d0f14;
--bg-stage: #111318;
--bg-titlebar: #161b27;
--bg-input: #0f1119;
/* Borders */
--border-structural: #1e2130;
--border-component: #2a2d36;
/* Text */
--text-primary: #c8ccd8;
--text-secondary: #8891a8;
--text-muted: #4a5068;
--text-ghost: #2e3348;
/* Accent */
--accent: #6ea6f0;
--accent-dim: rgba(110, 166, 240, 0.12);
--accent-hover: rgba(110, 166, 240, 0.08);
/* Status */
--status-green: #3ab86a;
--status-amber: #e09a2a;
--status-red: #e05a4a;
/* Fonts */
--font-mono: 'IBM Plex Mono', monospace;
--font-ui: 'Inter', sans-serif;
/* Spacing */
--dock-width: 72px;
--panel-width: 240px;
--tab-height: 40px;
--titlebar-height: 36px;
/* Touch targets */
--tap-min: 48px;
/* Radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Transitions */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
}

201
frontend/desktop.html Normal file
View file

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Atlus Desktop</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<!-- Atlus CSS -->
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/shell.css">
<link rel="stylesheet" href="/css/dock.css">
<link rel="stylesheet" href="/css/panel.css">
<link rel="stylesheet" href="/css/stage.css">
<link rel="stylesheet" href="/css/keyboard.css">
<link rel="stylesheet" href="/css/apps/terminal.css">
<link rel="stylesheet" href="/css/apps/files.css">
<link rel="stylesheet" href="/css/apps/services.css">
<link rel="stylesheet" href="/css/apps/tasks.css">
<link rel="stylesheet" href="/css/apps/settings.css">
</head>
<body>
<!-- ================================================================= -->
<!-- LEFT DOCK -->
<!-- ================================================================= -->
<aside id="dock" class="dock">
<div class="dock-top">
<button class="dock-item dock-logo" data-action="system-menu" title="System">
<span class="dock-icon">A</span>
<span class="dock-label">Atlus</span>
</button>
</div>
<nav class="dock-apps">
<button class="dock-item" data-app="terminal">
<span class="dock-icon">&gt;_</span>
<span class="dock-label">Terminal</span>
</button>
<button class="dock-item" data-app="files">
<span class="dock-icon">📁</span>
<span class="dock-label">Files</span>
</button>
<button class="dock-item" data-app="services">
<span class="dock-icon"></span>
<span class="dock-label">Services</span>
</button>
<button class="dock-item" data-app="tasks">
<span class="dock-icon">📊</span>
<span class="dock-label">Tasks</span>
</button>
<button class="dock-item" data-app="network">
<span class="dock-icon">🌐</span>
<span class="dock-label">Network</span>
</button>
<div class="dock-separator"></div>
<button class="dock-item" data-app="asi-bridge">
<span class="dock-icon">🔭</span>
<span class="dock-label">ASI Bridge</span>
</button>
</nav>
<div class="dock-bottom">
<button class="dock-item" data-app="settings">
<span class="dock-icon"></span>
<span class="dock-label">Settings</span>
</button>
</div>
</aside>
<!-- ================================================================= -->
<!-- CENTER STAGE -->
<!-- ================================================================= -->
<main id="stage" class="stage">
<!-- Tab bar -->
<div class="stage-tabbar" id="tabBar">
<div class="stage-tabs" id="stageTabs"></div>
<div class="stage-controls">
<button class="layout-btn active" data-layout="single" title="Single pane">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="layout-btn" data-layout="split" title="Split view">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="8" y1="1" x2="8" y2="15" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</div>
</div>
<!-- App panes -->
<div class="stage-panes" id="stagePanes">
<div class="stage-pane pane-primary" id="paneA">
<div class="pane-titlebar">
<div class="pane-dots">
<span class="dot dot-red"></span>
<span class="dot dot-amber"></span>
<span class="dot dot-green"></span>
</div>
<span class="pane-title" id="paneTitleA"></span>
</div>
<div class="pane-content" id="paneContentA">
<!-- Welcome state -->
<div class="welcome-screen" id="welcomeScreen">
<div class="welcome-logo">A</div>
<div class="welcome-text">Select an app from the dock to get started.</div>
</div>
</div>
</div>
<div class="stage-pane pane-secondary hidden" id="paneB">
<div class="pane-titlebar">
<div class="pane-dots">
<span class="dot dot-red"></span>
<span class="dot dot-amber"></span>
<span class="dot dot-green"></span>
</div>
<span class="pane-title" id="paneTitleB"></span>
</div>
<div class="pane-content" id="paneContentB"></div>
</div>
</div>
</main>
<!-- ================================================================= -->
<!-- RIGHT PANEL -->
<!-- ================================================================= -->
<aside id="panel" class="panel">
<!-- System info -->
<div class="panel-section panel-info">
<div class="panel-hostname" id="panelHostname">---</div>
<div class="panel-datetime">
<div class="panel-date" id="panelDate"></div>
<div class="panel-time" id="panelTime"></div>
</div>
</div>
<!-- Network -->
<div class="panel-section panel-network">
<div class="panel-section-title">NETWORK</div>
<div id="panelNetwork" class="panel-net-list"></div>
</div>
<!-- Stats -->
<div class="panel-section panel-stats">
<div class="panel-section-title">SYSTEM</div>
<div class="stat-row">
<span class="stat-label">CPU</span>
<span class="stat-value" id="statCpu">--%</span>
<div class="stat-bar"><div class="stat-bar-fill" id="statCpuBar"></div></div>
</div>
<div class="stat-row">
<span class="stat-label">MEM</span>
<span class="stat-value" id="statMem">--</span>
<div class="stat-bar"><div class="stat-bar-fill" id="statMemBar"></div></div>
</div>
<div class="stat-row">
<span class="stat-label">DISK</span>
<span class="stat-value" id="statDisk">--%</span>
<div class="stat-bar"><div class="stat-bar-fill" id="statDiskBar"></div></div>
</div>
<div class="stat-row">
<span class="stat-label">TEMP</span>
<span class="stat-value" id="statTemp">--°C</span>
<div class="stat-bar"><div class="stat-bar-fill" id="statTempBar"></div></div>
</div>
</div>
<!-- Services -->
<div class="panel-section panel-services">
<div class="panel-section-title">SERVICES</div>
<div id="panelServices" class="panel-service-list"></div>
</div>
</aside>
<!-- ================================================================= -->
<!-- System menu overlay -->
<!-- ================================================================= -->
<div class="system-menu hidden" id="systemMenu">
<div class="system-menu-content">
<button class="system-menu-item" data-action="lock">Lock Screen</button>
<button class="system-menu-item" data-action="logout">Log Out</button>
<div class="system-menu-sep"></div>
<button class="system-menu-item" data-action="reboot">Reboot</button>
<button class="system-menu-item system-menu-danger" data-action="shutdown">Shut Down</button>
<div class="system-menu-sep"></div>
<div class="system-menu-about">Atlus v0.1.0</div>
</div>
</div>
<!-- ================================================================= -->
<!-- Scripts -->
<!-- ================================================================= -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="/js/atlus.js"></script>
<script src="/js/keyboard.js"></script>
<script src="/js/apps/terminal.js"></script>
<script src="/js/apps/files.js"></script>
<script src="/js/apps/services.js"></script>
<script src="/js/apps/tasks.js"></script>
<script src="/js/apps/settings.js"></script>
<script src="/js/apps/asi_bridge.js"></script>
</body>
</html>

163
frontend/index.html Normal file
View file

@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Atlus</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/variables.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-ui);
background: var(--bg-dock);
color: var(--text-primary);
height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
.login-container {
width: 100%;
max-width: 360px;
padding: 24px;
}
.login-logo {
text-align: center;
margin-bottom: 48px;
}
.login-logo .logo-mark {
font-family: var(--font-mono);
font-size: 56px;
font-weight: 500;
color: var(--accent);
line-height: 1;
margin-bottom: 12px;
}
.login-logo .logo-name {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 400;
color: var(--text-muted);
letter-spacing: 6px;
text-transform: uppercase;
}
.login-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login-input {
width: 100%;
height: 48px;
padding: 0 16px;
background: var(--bg-stage);
border: 1px solid var(--border-structural);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.login-input::placeholder {
color: var(--text-muted);
}
.login-input:focus {
border-color: var(--accent);
}
.login-button {
width: 100%;
height: 48px;
margin-top: 8px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #fff;
font-family: var(--font-ui);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.login-button:hover {
opacity: 0.9;
}
.login-button:active {
opacity: 0.8;
}
.login-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
text-align: center;
color: var(--status-red);
font-size: 13px;
min-height: 20px;
margin-top: 4px;
}
.login-footer {
text-align: center;
margin-top: 48px;
color: var(--text-ghost);
font-size: 12px;
font-family: var(--font-mono);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-logo">
<div class="logo-mark">A</div>
<div class="logo-name">Atlus</div>
</div>
<form class="login-form" id="loginForm">
<input
type="text"
class="login-input"
id="username"
placeholder="Username"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
required
>
<input
type="password"
class="login-input"
id="password"
placeholder="Password"
autocomplete="current-password"
required
>
<button type="submit" class="login-button" id="loginBtn">Sign In</button>
<div class="login-error" id="loginError"></div>
</form>
<div class="login-footer">v0.1.0</div>
</div>
<script src="/js/auth.js"></script>
</body>
</html>

View file

@ -0,0 +1,176 @@
/* Atlus — ASI Bridge plugin app */
(function () {
'use strict';
let container = null;
let ws = null;
let statusEl = null;
let filesEl = null;
async function loadStatus() {
try {
const res = await Atlus.apiFetch('/api/plugins/asi-bridge/status');
if (!res.ok) return;
const data = await res.json();
renderStatus(data);
} catch (e) {}
}
function renderStatus(data) {
if (!statusEl) return;
const mountClass = data.mounted ? 'var(--status-green)' : 'var(--status-red)';
const mountText = data.mounted ? 'Connected' : 'Disconnected';
statusEl.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:24px;">
<div style="width:12px;height:12px;border-radius:50%;background:${mountClass};"></div>
<div>
<div style="font-family:var(--font-ui);font-size:16px;font-weight:500;color:var(--text-primary);">ASI Air</div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-secondary);">${mountText}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:24px;">
<div style="background:var(--bg-titlebar);border:1px solid var(--border-structural);border-radius:var(--radius-md);padding:16px;">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:4px;">FITS FILES</div>
<div style="font-family:var(--font-mono);font-size:24px;font-weight:500;color:var(--text-primary);">${data.fits_count}</div>
</div>
<div style="background:var(--bg-titlebar);border:1px solid var(--border-structural);border-radius:var(--radius-md);padding:16px;">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:4px;">TOTAL SIZE</div>
<div style="font-family:var(--font-mono);font-size:24px;font-weight:500;color:var(--text-primary);">${Atlus.formatBytes(data.total_size)}</div>
</div>
</div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);margin-bottom:8px;">
Share: ${data.cifs_share || 'Not configured'}<br>
Mount: ${data.mount_point}
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="settings-btn ${data.mounted ? 'secondary' : ''}" id="asiBridgeMount">
${data.mounted ? 'Unmount' : 'Mount'}
</button>
</div>
`;
const mountBtn = statusEl.querySelector('#asiBridgeMount');
mountBtn.addEventListener('click', async () => {
const endpoint = data.mounted ? '/api/plugins/asi-bridge/unmount' : '/api/plugins/asi-bridge/mount';
await Atlus.apiFetch(endpoint, { method: 'POST' });
loadStatus();
loadFiles();
});
// Latest file
if (data.latest_file) {
const latest = document.createElement('div');
latest.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg-titlebar);border:1px solid var(--border-structural);border-radius:var(--radius-md);';
latest.innerHTML = `
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:4px;">LATEST CAPTURE</div>
<div style="font-family:var(--font-mono);font-size:13px;color:var(--text-primary);">${data.latest_file.name}</div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);margin-top:4px;">
${Atlus.formatBytes(data.latest_file.size)} &middot;
${new Date(data.latest_file.modified * 1000).toLocaleString()}
</div>
`;
statusEl.appendChild(latest);
}
}
async function loadFiles() {
if (!filesEl) return;
try {
const res = await Atlus.apiFetch('/api/plugins/asi-bridge/files');
if (!res.ok) {
filesEl.innerHTML = '<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:12px;padding:16px;">Share not mounted.</div>';
return;
}
const files = await res.json();
renderFiles(files);
} catch (e) {
filesEl.innerHTML = '<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:12px;padding:16px;">Unable to load files.</div>';
}
}
function renderFiles(files) {
if (!filesEl) return;
filesEl.innerHTML = '';
if (files.length === 0) {
filesEl.innerHTML = '<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:12px;padding:16px;">No FITS files found.</div>';
return;
}
const header = document.createElement('div');
header.style.cssText = 'display:grid;grid-template-columns:1fr 80px 140px;padding:0 16px;height:32px;align-items:center;font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:0.5px;border-bottom:1px solid var(--border-structural);';
header.innerHTML = '<span>Name</span><span>Size</span><span>Modified</span>';
filesEl.appendChild(header);
files.slice(0, 100).forEach(file => {
const row = document.createElement('div');
row.style.cssText = 'display:grid;grid-template-columns:1fr 80px 140px;padding:0 16px;min-height:44px;align-items:center;border-bottom:1px solid var(--border-structural);font-family:var(--font-mono);font-size:12px;';
row.innerHTML = `
<span style="color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${file.relative}">${file.name}</span>
<span style="color:var(--text-secondary);">${Atlus.formatBytes(file.size)}</span>
<span style="color:var(--text-secondary);">${new Date(file.modified * 1000).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}</span>
`;
filesEl.appendChild(row);
});
}
function connectWs() {
ws = new WebSocket(Atlus.wsUrl('/api/plugins/asi-bridge/ws'));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'mount_status') {
loadStatus();
} else if (msg.type === 'new_file') {
loadFiles();
}
};
ws.onclose = () => {
setTimeout(connectWs, 5000);
};
}
Atlus.registerApp('asi-bridge', {
title: 'ASI Bridge',
init(el) {
container = el;
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.height = '100%';
container.style.overflow = 'auto';
// Status section
statusEl = document.createElement('div');
statusEl.style.padding = '24px';
statusEl.style.borderBottom = '1px solid var(--border-structural)';
container.appendChild(statusEl);
// Files section
const filesHeader = document.createElement('div');
filesHeader.style.cssText = 'padding:16px 24px 8px;font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--text-muted);letter-spacing:1.5px;';
filesHeader.textContent = 'CAPTURES';
container.appendChild(filesHeader);
filesEl = document.createElement('div');
filesEl.style.flex = '1';
filesEl.style.overflow = 'auto';
container.appendChild(filesEl);
loadStatus();
loadFiles();
connectWs();
},
destroy() {
if (ws) { ws.close(); ws = null; }
container = null;
statusEl = null;
filesEl = null;
},
});
})();

353
frontend/js/apps/files.js Normal file
View file

@ -0,0 +1,353 @@
/* Atlus — File Manager app */
(function () {
'use strict';
let container = null;
let currentPath = '/';
let fileListEl = null;
let breadcrumbEl = null;
let sidebarEl = null;
let selectedFiles = new Set();
let contextMenuEl = null;
const FILE_ICONS = {
dir: '📁',
file: '📄',
image: '🖼',
video: '🎬',
audio: '🎵',
archive: '📦',
code: '📝',
text: '📝',
};
function getFileIcon(entry) {
if (entry.is_dir) return FILE_ICONS.dir;
const ext = entry.name.split('.').pop().toLowerCase();
if (['jpg','jpeg','png','gif','bmp','svg','webp','fits','fit'].includes(ext)) return FILE_ICONS.image;
if (['mp4','mkv','avi','mov','webm'].includes(ext)) return FILE_ICONS.video;
if (['mp3','flac','wav','ogg','aac'].includes(ext)) return FILE_ICONS.audio;
if (['zip','tar','gz','bz2','xz','7z','rar'].includes(ext)) return FILE_ICONS.archive;
if (['js','py','sh','css','html','json','yml','yaml','toml','rs','go','c','cpp','h'].includes(ext)) return FILE_ICONS.code;
return FILE_ICONS.file;
}
function formatSize(bytes) {
return Atlus.formatBytes(bytes);
}
function formatDate(ts) {
return new Date(ts * 1000).toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
});
}
async function loadDirectory(path) {
currentPath = path;
selectedFiles.clear();
updateBreadcrumb();
try {
const res = await Atlus.apiFetch(`/api/files/list?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error('Failed to load directory');
const entries = await res.json();
renderFileList(entries);
} catch (e) {
fileListEl.innerHTML = `<div style="padding:16px;color:var(--status-red);font-family:var(--font-mono);font-size:13px;">Error: ${e.message}</div>`;
}
// Update sidebar active
if (sidebarEl) {
sidebarEl.querySelectorAll('.sidebar-item').forEach(item => {
item.classList.toggle('active', item.dataset.path === path);
});
}
}
function renderFileList(entries) {
fileListEl.innerHTML = '';
// Parent directory entry
if (currentPath !== '/') {
const parentRow = document.createElement('div');
parentRow.className = 'file-row';
parentRow.innerHTML = `
<div class="file-name"><span class="file-icon dir">..</span><span>..</span></div>
<span class="file-size"></span>
<span class="file-modified"></span>
<span class="file-perms"></span>
`;
parentRow.addEventListener('click', () => {
const parent = currentPath.replace(/\/[^/]+\/?$/, '') || '/';
loadDirectory(parent);
});
fileListEl.appendChild(parentRow);
}
entries.forEach(entry => {
const row = document.createElement('div');
row.className = 'file-row';
row.dataset.path = entry.path;
const icon = getFileIcon(entry);
row.innerHTML = `
<div class="file-name">
<span class="file-icon ${entry.is_dir ? 'dir' : ''}">${icon}</span>
<span>${entry.name}</span>
</div>
<span class="file-size">${entry.is_dir ? '--' : formatSize(entry.size)}</span>
<span class="file-modified">${formatDate(entry.modified)}</span>
<span class="file-perms">${entry.permissions}</span>
`;
row.addEventListener('click', () => {
if (entry.is_dir) {
loadDirectory(entry.path);
} else {
// Toggle selection
row.classList.toggle('selected');
if (selectedFiles.has(entry.path)) {
selectedFiles.delete(entry.path);
} else {
selectedFiles.add(entry.path);
}
}
});
// Long-press for context menu
let pressTimer;
row.addEventListener('touchstart', (e) => {
pressTimer = setTimeout(() => showContextMenu(e, entry), 500);
});
row.addEventListener('touchend', () => clearTimeout(pressTimer));
row.addEventListener('touchmove', () => clearTimeout(pressTimer));
// Right-click fallback
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(e, entry);
});
fileListEl.appendChild(row);
});
}
function updateBreadcrumb() {
breadcrumbEl.innerHTML = '';
const parts = currentPath.split('/').filter(Boolean);
// Root
const root = document.createElement('button');
root.className = 'breadcrumb-segment';
root.textContent = '/';
root.addEventListener('click', () => loadDirectory('/'));
breadcrumbEl.appendChild(root);
let path = '';
parts.forEach((part, i) => {
path += '/' + part;
const sep = document.createElement('span');
sep.className = 'breadcrumb-sep';
sep.textContent = '/';
breadcrumbEl.appendChild(sep);
const seg = document.createElement('button');
seg.className = 'breadcrumb-segment';
seg.textContent = part;
const segPath = path;
seg.addEventListener('click', () => loadDirectory(segPath));
breadcrumbEl.appendChild(seg);
});
}
function showContextMenu(e, entry) {
hideContextMenu();
contextMenuEl = document.createElement('div');
contextMenuEl.className = 'file-context-menu';
const items = [
{ label: 'Open', action: () => entry.is_dir ? loadDirectory(entry.path) : previewFile(entry) },
{ label: 'Rename', action: () => renameFile(entry) },
{ label: 'Copy', action: () => { /* clipboard */ } },
{ label: 'Move', action: () => { /* clipboard */ } },
{ sep: true },
{ label: 'Delete', action: () => deleteFile(entry), danger: true },
];
items.forEach(item => {
if (item.sep) {
const sep = document.createElement('div');
sep.className = 'context-sep';
contextMenuEl.appendChild(sep);
return;
}
const btn = document.createElement('button');
btn.className = 'context-item' + (item.danger ? ' danger' : '');
btn.textContent = item.label;
btn.addEventListener('click', () => {
hideContextMenu();
item.action();
});
contextMenuEl.appendChild(btn);
});
// Position
const x = e.touches ? e.touches[0].clientX : e.clientX;
const y = e.touches ? e.touches[0].clientY : e.clientY;
contextMenuEl.style.left = x + 'px';
contextMenuEl.style.top = y + 'px';
document.body.appendChild(contextMenuEl);
// Close on outside click
setTimeout(() => {
document.addEventListener('click', hideContextMenu, { once: true });
}, 10);
}
function hideContextMenu() {
if (contextMenuEl) {
contextMenuEl.remove();
contextMenuEl = null;
}
}
async function deleteFile(entry) {
if (!confirm(`Delete "${entry.name}"?`)) return;
await Atlus.apiFetch('/api/files/delete', {
method: 'POST',
body: { path: entry.path },
});
loadDirectory(currentPath);
}
async function renameFile(entry) {
const newName = prompt('New name:', entry.name);
if (!newName || newName === entry.name) return;
await Atlus.apiFetch('/api/files/rename', {
method: 'POST',
body: { old_path: entry.path, new_name: newName },
});
loadDirectory(currentPath);
}
async function previewFile(entry) {
// Simple text preview
try {
const res = await Atlus.apiFetch(`/api/files/read?path=${encodeURIComponent(entry.path)}`);
if (res.ok) {
const data = await res.json();
alert(data.content.substring(0, 2000));
}
} catch (e) {}
}
async function loadMounts() {
try {
const res = await Atlus.apiFetch('/api/files/mounts');
if (!res.ok) return;
const mounts = await res.json();
// Add mount entries to sidebar
const heading = document.createElement('div');
heading.className = 'sidebar-heading';
heading.textContent = 'MOUNTS';
sidebarEl.appendChild(heading);
mounts.forEach(mount => {
if (mount.mountpoint === '/') return; // Already in sidebar
const item = document.createElement('button');
item.className = 'sidebar-item';
item.dataset.path = mount.mountpoint;
item.innerHTML = `<span class="sidebar-icon">💾</span>${mount.mountpoint.split('/').pop() || mount.mountpoint}`;
item.addEventListener('click', () => loadDirectory(mount.mountpoint));
sidebarEl.appendChild(item);
});
} catch (e) {}
}
Atlus.registerApp('files', {
title: 'Files',
init(el) {
container = el;
container.classList.add('app-files');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'files-toolbar';
breadcrumbEl = document.createElement('div');
breadcrumbEl.className = 'files-breadcrumb';
toolbar.appendChild(breadcrumbEl);
// Refresh button
const refreshBtn = document.createElement('button');
refreshBtn.className = 'files-action-btn';
refreshBtn.textContent = '↻';
refreshBtn.title = 'Refresh';
refreshBtn.addEventListener('click', () => loadDirectory(currentPath));
toolbar.appendChild(refreshBtn);
container.appendChild(toolbar);
// Body
const body = document.createElement('div');
body.className = 'files-body';
// Sidebar
sidebarEl = document.createElement('div');
sidebarEl.className = 'files-sidebar';
const homeHeading = document.createElement('div');
homeHeading.className = 'sidebar-heading';
homeHeading.textContent = 'PLACES';
sidebarEl.appendChild(homeHeading);
const places = [
{ icon: '🏠', label: 'Home', path: `/home/${Atlus.user}` },
{ icon: '/', label: 'Root', path: '/' },
{ icon: '📁', label: 'tmp', path: '/tmp' },
];
places.forEach(p => {
const item = document.createElement('button');
item.className = 'sidebar-item';
item.dataset.path = p.path;
item.innerHTML = `<span class="sidebar-icon">${p.icon}</span>${p.label}`;
item.addEventListener('click', () => loadDirectory(p.path));
sidebarEl.appendChild(item);
});
body.appendChild(sidebarEl);
// File list panel
const listPanel = document.createElement('div');
listPanel.className = 'files-list-panel';
const header = document.createElement('div');
header.className = 'files-list-header';
header.innerHTML = '<span>Name</span><span>Size</span><span>Modified</span><span>Perms</span>';
listPanel.appendChild(header);
fileListEl = document.createElement('div');
fileListEl.className = 'files-list';
listPanel.appendChild(fileListEl);
body.appendChild(listPanel);
container.appendChild(body);
// Load initial directory
loadDirectory(`/home/${Atlus.user}`);
loadMounts();
},
destroy() {
hideContextMenu();
container = null;
fileListEl = null;
breadcrumbEl = null;
sidebarEl = null;
currentPath = '/';
selectedFiles.clear();
},
});
})();

View file

@ -0,0 +1,179 @@
/* Atlus — Service Manager app */
(function () {
'use strict';
let container = null;
let listEl = null;
let searchInput = null;
let allServices = [];
let filterActive = false;
async function loadServices() {
try {
const res = await Atlus.apiFetch('/api/services');
if (!res.ok) return;
allServices = await res.json();
renderServices();
} catch (e) {}
}
function renderServices() {
if (!listEl) return;
const query = searchInput ? searchInput.value.toLowerCase() : '';
let filtered = allServices;
if (query) {
filtered = filtered.filter(s =>
s.name.toLowerCase().includes(query) ||
s.description.toLowerCase().includes(query)
);
}
if (filterActive) {
filtered = filtered.filter(s => s.active === 'active');
}
listEl.innerHTML = '';
// Header
const header = document.createElement('div');
header.className = 'services-header';
header.innerHTML = '<span></span><span>Service</span><span>State</span><span>Sub</span><span></span>';
listEl.appendChild(header);
filtered.forEach(svc => {
const row = document.createElement('div');
row.className = 'service-row';
const isActive = svc.active === 'active';
const stateClass = svc.active === 'active' ? 'active' :
svc.active === 'failed' ? 'failed' : 'inactive';
row.innerHTML = `
<div class="svc-toggle">
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${svc.unit}"></button>
</div>
<div>
<div class="svc-name">${svc.name}</div>
<div class="svc-desc">${svc.description}</div>
</div>
<span class="svc-state ${stateClass}">${svc.active}</span>
<span class="svc-sub">${svc.sub}</span>
<div class="svc-actions">
<button class="svc-action-btn" data-unit="${svc.unit}" data-action="restart" title="Restart"></button>
</div>
`;
// Toggle handler
const toggle = row.querySelector('.service-toggle');
toggle.addEventListener('click', async () => {
const action = toggle.classList.contains('on') ? 'stop' : 'start';
await Atlus.apiFetch('/api/services/action', {
method: 'POST',
body: { unit: svc.unit, action },
});
loadServices();
});
// Restart handler
const restartBtn = row.querySelector('[data-action="restart"]');
restartBtn.addEventListener('click', async () => {
await Atlus.apiFetch('/api/services/action', {
method: 'POST',
body: { unit: svc.unit, action: 'restart' },
});
loadServices();
});
listEl.appendChild(row);
});
}
Atlus.registerApp('services', {
title: 'Services',
init(el) {
container = el;
container.classList.add('app-services');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'services-toolbar';
searchInput = document.createElement('input');
searchInput.className = 'services-search';
searchInput.type = 'text';
searchInput.placeholder = 'Search services…';
searchInput.addEventListener('input', renderServices);
toolbar.appendChild(searchInput);
const filterBtn = document.createElement('button');
filterBtn.className = 'services-filter-btn';
filterBtn.textContent = 'Active';
filterBtn.addEventListener('click', () => {
filterActive = !filterActive;
filterBtn.classList.toggle('active', filterActive);
renderServices();
});
toolbar.appendChild(filterBtn);
container.appendChild(toolbar);
// List
listEl = document.createElement('div');
listEl.className = 'services-list';
container.appendChild(listEl);
loadServices();
},
destroy() {
container = null;
listEl = null;
searchInput = null;
allServices = [];
filterActive = false;
},
});
// Also register as "network" since it's a similar list-based view
Atlus.registerApp('network', {
title: 'Network',
init(el) {
el.classList.add('app-view');
el.style.padding = '24px';
el.style.overflow = 'auto';
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);">Loading network info…</div>';
Atlus.apiFetch('/api/stats').then(res => res.json()).then(data => {
let html = '<div style="font-family:var(--font-mono);font-size:13px;">';
const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) {
html += `
<div style="margin-bottom:24px;padding:16px;background:var(--bg-titlebar);border:1px solid var(--border-structural);border-radius:var(--radius-md);">
<div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:12px;">${name}</div>
<div style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;color:var(--text-secondary);">
<span style="color:var(--text-muted);">Status</span>
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
<span style="color:var(--text-muted);">IPv4</span>
<span>${info.ipv4 || '--'}</span>
<span style="color:var(--text-muted);">IPv6</span>
<span>${info.ipv6 || '--'}</span>
</div>
</div>
`;
}
html += `
<div style="color:var(--text-muted);margin-top:8px;">
Total sent: ${Atlus.formatBytes(data.network.bytes_sent)} &middot;
Total recv: ${Atlus.formatBytes(data.network.bytes_recv)}
</div>
`;
html += '</div>';
el.innerHTML = html;
});
},
destroy() {},
});
})();

View file

@ -0,0 +1,251 @@
/* Atlus — Settings app */
(function () {
'use strict';
let container = null;
let contentEl = null;
let activeSection = 'general';
const SECTIONS = [
{ id: 'general', label: 'General' },
{ id: 'network', label: 'Network' },
{ id: 'services', label: 'Services' },
{ id: 'about', label: 'About' },
];
async function loadSection(section) {
activeSection = section;
// Update nav
container.querySelectorAll('.settings-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.section === section);
});
if (section === 'general') await renderGeneral();
else if (section === 'network') await renderNetwork();
else if (section === 'services') await renderServicesConfig();
else if (section === 'about') await renderAbout();
}
async function renderGeneral() {
const res = await Atlus.apiFetch('/api/settings');
const cfg = await res.json();
const sysRes = await Atlus.apiFetch('/api/settings/system');
const sys = await sysRes.json();
contentEl.innerHTML = `
<div class="settings-section-title">General</div>
<div class="settings-group">
<div class="settings-group-title">SYSTEM</div>
<div class="settings-row">
<div>
<div class="settings-row-label">Hostname</div>
<div class="settings-row-desc">System network name</div>
</div>
<input class="settings-input" id="setHostname" value="${sys.hostname}">
</div>
<div class="settings-row">
<div>
<div class="settings-row-label">Timezone</div>
<div class="settings-row-desc">System timezone</div>
</div>
<input class="settings-input" id="setTimezone" value="${cfg.timezone || 'System default'}" placeholder="e.g. America/New_York">
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">DISPLAY</div>
<div class="settings-row">
<div>
<div class="settings-row-label">Stats refresh interval</div>
<div class="settings-row-desc">Seconds between stat updates</div>
</div>
<input class="settings-input" id="setStatsInterval" type="number" min="1" max="30" value="${cfg.stats_interval_seconds || 2}" style="width:80px;">
</div>
<div class="settings-row">
<div>
<div class="settings-row-label">Session timeout</div>
<div class="settings-row-desc">Minutes before auto-logout</div>
</div>
<input class="settings-input" id="setSessionTimeout" type="number" min="5" max="10080" value="${cfg.session_timeout_minutes || 1440}" style="width:80px;">
</div>
</div>
<div class="settings-actions">
<button class="settings-btn" id="saveGeneral">Save</button>
</div>
`;
contentEl.querySelector('#saveGeneral').addEventListener('click', async () => {
const hostname = contentEl.querySelector('#setHostname').value.trim();
const timezone = contentEl.querySelector('#setTimezone').value.trim();
const interval = parseInt(contentEl.querySelector('#setStatsInterval').value);
const timeout = parseInt(contentEl.querySelector('#setSessionTimeout').value);
// Update hostname if changed
if (hostname && hostname !== sys.hostname) {
await Atlus.apiFetch('/api/settings/hostname', {
method: 'POST',
body: { hostname },
});
}
// Update timezone if changed
if (timezone && timezone !== 'System default') {
await Atlus.apiFetch('/api/settings/timezone', {
method: 'POST',
body: { timezone },
});
}
// Update config
await Atlus.apiFetch('/api/settings', {
method: 'PUT',
body: {
stats_interval_seconds: interval,
session_timeout_minutes: timeout,
timezone: timezone === 'System default' ? null : timezone,
},
});
alert('Settings saved.');
});
}
async function renderNetwork() {
const res = await Atlus.apiFetch('/api/stats');
const data = await res.json();
let html = '<div class="settings-section-title">Network</div>';
const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) {
html += `
<div class="settings-group">
<div class="settings-group-title">${name.toUpperCase()}</div>
<div class="settings-row">
<div class="settings-row-label">Status</div>
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">IPv4</div>
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv4 || '--'}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">IPv6</div>
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv6 || '--'}</span>
</div>
</div>
`;
}
contentEl.innerHTML = html;
}
async function renderServicesConfig() {
const cfgRes = await Atlus.apiFetch('/api/settings');
const cfg = await cfgRes.json();
const panelServices = cfg.panel_services || [];
contentEl.innerHTML = `
<div class="settings-section-title">Panel Services</div>
<div class="settings-group">
<div class="settings-group-title">PINNED TO RIGHT PANEL</div>
<div class="settings-row-desc" style="margin-bottom:16px;">
Comma-separated list of systemd unit names to show in the right panel.
</div>
<textarea class="settings-input" id="setPanelServices" rows="4"
style="width:100%;height:100px;padding:12px;resize:vertical;"
placeholder="e.g. nginx.service, docker.service">${panelServices.join(', ')}</textarea>
</div>
<div class="settings-actions">
<button class="settings-btn" id="saveServices">Save</button>
</div>
`;
contentEl.querySelector('#saveServices').addEventListener('click', async () => {
const raw = contentEl.querySelector('#setPanelServices').value;
const units = raw.split(',').map(s => s.trim()).filter(Boolean);
await Atlus.apiFetch('/api/settings', {
method: 'PUT',
body: { panel_services: units },
});
alert('Panel services updated.');
});
}
async function renderAbout() {
const res = await Atlus.apiFetch('/api/settings/system');
const sys = await res.json();
contentEl.innerHTML = `
<div class="settings-section-title">About</div>
<div class="settings-group">
<div style="text-align:center;margin-bottom:32px;">
<div style="font-family:var(--font-mono);font-size:48px;font-weight:500;color:var(--accent);margin-bottom:8px;">A</div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);letter-spacing:4px;">ATLUS v0.1.0</div>
</div>
<div class="settings-row">
<div class="settings-row-label">Hostname</div>
<span style="font-family:var(--font-mono);font-size:13px;">${sys.hostname}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">Operating System</div>
<span style="font-family:var(--font-mono);font-size:13px;">${sys.os}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">Kernel</div>
<span style="font-family:var(--font-mono);font-size:13px;">${sys.kernel}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">Architecture</div>
<span style="font-family:var(--font-mono);font-size:13px;">${sys.arch}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">Python</div>
<span style="font-family:var(--font-mono);font-size:13px;">${sys.python}</span>
</div>
</div>
<div style="text-align:center;margin-top:32px;">
<div style="color:var(--text-muted);font-size:12px;font-family:var(--font-mono);">
Licensed under GPL-3.0
</div>
</div>
`;
}
Atlus.registerApp('settings', {
title: 'Settings',
init(el) {
container = el;
container.classList.add('app-settings');
// Nav
const nav = document.createElement('div');
nav.className = 'settings-nav';
SECTIONS.forEach(s => {
const item = document.createElement('button');
item.className = 'settings-nav-item' + (s.id === activeSection ? ' active' : '');
item.dataset.section = s.id;
item.textContent = s.label;
item.addEventListener('click', () => loadSection(s.id));
nav.appendChild(item);
});
container.appendChild(nav);
// Content
contentEl = document.createElement('div');
contentEl.className = 'settings-content';
container.appendChild(contentEl);
loadSection(activeSection);
},
destroy() {
container = null;
contentEl = null;
activeSection = 'general';
},
});
})();

163
frontend/js/apps/tasks.js Normal file
View file

@ -0,0 +1,163 @@
/* Atlus — Task Manager app */
(function () {
'use strict';
let container = null;
let listEl = null;
let searchInput = null;
let processes = [];
let sortCol = 'cpu';
let sortDir = -1; // -1 = desc, 1 = asc
let refreshInterval = null;
async function loadProcesses() {
try {
const res = await Atlus.apiFetch('/api/processes');
if (!res.ok) return;
processes = await res.json();
renderProcesses();
} catch (e) {}
}
function renderProcesses() {
if (!listEl) return;
const query = searchInput ? searchInput.value.toLowerCase() : '';
let filtered = processes;
if (query) {
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(query) ||
String(p.pid).includes(query) ||
(p.user && p.user.toLowerCase().includes(query))
);
}
// Sort
filtered.sort((a, b) => {
const av = a[sortCol] ?? 0;
const bv = b[sortCol] ?? 0;
if (typeof av === 'string') return av.localeCompare(bv) * sortDir;
return (av - bv) * sortDir;
});
// Summary
const totalCpu = processes.reduce((s, p) => s + (p.cpu || 0), 0);
const totalMem = processes.reduce((s, p) => s + (p.mem || 0), 0);
const summaryEl = container.querySelector('.tasks-summary');
if (summaryEl) {
summaryEl.innerHTML = `
<div class="tasks-summary-item">
<span class="tasks-summary-label">PROCESSES</span>
<span class="tasks-summary-value">${processes.length}</span>
</div>
<div class="tasks-summary-item">
<span class="tasks-summary-label">CPU</span>
<span class="tasks-summary-value">${totalCpu.toFixed(1)}%</span>
</div>
<div class="tasks-summary-item">
<span class="tasks-summary-label">MEM</span>
<span class="tasks-summary-value">${totalMem.toFixed(1)}%</span>
</div>
`;
}
listEl.innerHTML = '';
// Header
const header = document.createElement('div');
header.className = 'tasks-header';
const cols = [
{ key: 'pid', label: 'PID' },
{ key: 'name', label: 'NAME' },
{ key: 'user', label: 'USER' },
{ key: 'cpu', label: 'CPU %' },
{ key: 'mem', label: 'MEM %' },
{ key: 'status', label: 'STATUS' },
{ key: '', label: '' },
];
cols.forEach(col => {
const span = document.createElement('span');
span.textContent = col.label;
if (col.key === sortCol) span.classList.add('sorted');
if (col.key) {
span.addEventListener('click', () => {
if (sortCol === col.key) sortDir *= -1;
else { sortCol = col.key; sortDir = -1; }
renderProcesses();
});
}
header.appendChild(span);
});
listEl.appendChild(header);
filtered.forEach(proc => {
const row = document.createElement('div');
row.className = 'proc-row';
row.innerHTML = `
<span class="proc-pid">${proc.pid}</span>
<span class="proc-name" title="${proc.cmdline || proc.name}">${proc.name}</span>
<span class="proc-user">${proc.user || '--'}</span>
<span class="proc-cpu">${(proc.cpu || 0).toFixed(1)}</span>
<span class="proc-mem">${(proc.mem || 0).toFixed(1)}</span>
<span class="proc-status">${proc.status}</span>
<button class="proc-kill" title="Kill process" data-pid="${proc.pid}">&times;</button>
`;
const killBtn = row.querySelector('.proc-kill');
killBtn.addEventListener('click', async () => {
if (!confirm(`Kill process ${proc.name} (PID ${proc.pid})?`)) return;
await Atlus.apiFetch('/api/processes/signal', {
method: 'POST',
body: { pid: proc.pid, signal: 'SIGTERM' },
});
setTimeout(loadProcesses, 500);
});
listEl.appendChild(row);
});
}
Atlus.registerApp('tasks', {
title: 'Task Manager',
init(el) {
container = el;
container.classList.add('app-tasks');
// Summary
const summary = document.createElement('div');
summary.className = 'tasks-summary';
container.appendChild(summary);
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'tasks-toolbar';
searchInput = document.createElement('input');
searchInput.className = 'tasks-search';
searchInput.type = 'text';
searchInput.placeholder = 'Filter processes…';
searchInput.addEventListener('input', renderProcesses);
toolbar.appendChild(searchInput);
container.appendChild(toolbar);
// List
listEl = document.createElement('div');
listEl.className = 'tasks-list';
container.appendChild(listEl);
loadProcesses();
refreshInterval = setInterval(loadProcesses, 3000);
},
destroy() {
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = null;
container = null;
listEl = null;
searchInput = null;
processes = [];
},
});
})();

View file

@ -0,0 +1,280 @@
/* Atlus — Terminal app (xterm.js + PTY WebSocket) */
(function () {
'use strict';
let sessions = []; // {id, ws, term, fitAddon}
let activeSession = 0;
let sessionCounter = 0;
let container = null;
let termContainer = null;
let tabsContainer = null;
let kbdVisible = true;
function createSession() {
const id = ++sessionCounter;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "'IBM Plex Mono', monospace",
theme: {
background: '#111318',
foreground: '#c8ccd8',
cursor: '#6ea6f0',
selectionBackground: 'rgba(110, 166, 240, 0.3)',
black: '#0d0f14',
red: '#e05a4a',
green: '#3ab86a',
yellow: '#e09a2a',
blue: '#6ea6f0',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#c8ccd8',
brightBlack: '#4a5068',
brightRed: '#e05a4a',
brightGreen: '#3ab86a',
brightYellow: '#e09a2a',
brightBlue: '#6ea6f0',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const ws = new WebSocket(Atlus.wsUrl('/api/terminal/ws'));
ws.onopen = () => {
// Send initial size
setTimeout(() => {
fitAddon.fit();
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}));
}, 100);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'output') {
term.write(msg.data);
}
};
ws.onclose = () => {
term.write('\r\n\x1b[31m[Session ended]\x1b[0m\r\n');
};
// Send input to PTY
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
// Handle resize
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
const session = { id, ws, term, fitAddon };
sessions.push(session);
// Connect keyboard — only send to PTY, let PTY echo handle display
Atlus.keyboard.setTerminal({
input: (data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
},
});
return session;
}
function switchSession(id) {
activeSession = id;
renderTabs();
renderTerminal();
}
function closeSession(id) {
const idx = sessions.findIndex(s => s.id === id);
if (idx === -1) return;
const session = sessions[idx];
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.close();
}
session.term.dispose();
sessions.splice(idx, 1);
if (sessions.length === 0) {
Atlus.closeApp('terminal');
return;
}
if (activeSession === id) {
activeSession = sessions[sessions.length - 1].id;
}
renderTabs();
renderTerminal();
}
function renderTabs() {
if (!tabsContainer) return;
tabsContainer.innerHTML = '';
sessions.forEach(s => {
const tab = document.createElement('button');
tab.className = 'terminal-tab' + (s.id === activeSession ? ' active' : '');
tab.innerHTML = `<span>Shell ${s.id}</span>`;
if (sessions.length > 1) {
tab.innerHTML += `<span class="terminal-tab-close">&times;</span>`;
}
tab.addEventListener('click', (e) => {
if (e.target.classList.contains('terminal-tab-close')) {
closeSession(s.id);
} else {
switchSession(s.id);
}
});
tabsContainer.appendChild(tab);
});
}
function renderTerminal() {
if (!termContainer) return;
// Clear
termContainer.innerHTML = '';
const session = sessions.find(s => s.id === activeSession);
if (!session) return;
const wrap = document.createElement('div');
wrap.className = 'terminal-container';
termContainer.appendChild(wrap);
session.term.open(wrap);
// Fit after render
requestAnimationFrame(() => {
session.fitAddon.fit();
});
// Update keyboard terminal ref — only send to PTY
Atlus.keyboard.setTerminal({
input: (data) => {
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({ type: 'input', data }));
}
},
});
}
// Resize observer
let resizeObserver = null;
Atlus.registerApp('terminal', {
title: 'Terminal',
init(el) {
container = el;
container.classList.add('app-terminal');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'terminal-toolbar';
tabsContainer = document.createElement('div');
tabsContainer.className = 'terminal-tabs';
toolbar.appendChild(tabsContainer);
const newTabBtn = document.createElement('button');
newTabBtn.className = 'terminal-new-tab';
newTabBtn.textContent = '+';
newTabBtn.title = 'New tab';
newTabBtn.addEventListener('click', () => {
const s = createSession();
switchSession(s.id);
});
toolbar.appendChild(newTabBtn);
const kbdBtn = document.createElement('button');
kbdBtn.className = 'terminal-kbd-toggle' + (kbdVisible ? ' active' : '');
kbdBtn.textContent = '⌨';
kbdBtn.title = 'Toggle keyboard';
kbdBtn.addEventListener('click', () => {
kbdVisible = !kbdVisible;
kbdBtn.classList.toggle('active', kbdVisible);
Atlus.keyboard.toggle();
// Refit terminal
const session = sessions.find(s => s.id === activeSession);
if (session) {
requestAnimationFrame(() => session.fitAddon.fit());
}
});
toolbar.appendChild(kbdBtn);
container.appendChild(toolbar);
// Terminal area
termContainer = document.createElement('div');
termContainer.style.flex = '1';
termContainer.style.overflow = 'hidden';
termContainer.style.display = 'flex';
termContainer.style.flexDirection = 'column';
container.appendChild(termContainer);
// On-screen keyboard
const kbdEl = Atlus.keyboard.create();
if (!kbdVisible) kbdEl.classList.add('hidden');
container.appendChild(kbdEl);
// Create first session
const s = createSession();
activeSession = s.id;
renderTabs();
renderTerminal();
// Resize observer
resizeObserver = new ResizeObserver(() => {
const session = sessions.find(s => s.id === activeSession);
if (session) {
try { session.fitAddon.fit(); } catch (e) {}
}
});
resizeObserver.observe(termContainer);
},
destroy() {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
sessions.forEach(s => {
if (s.ws.readyState === WebSocket.OPEN) s.ws.close();
s.term.dispose();
});
sessions = [];
sessionCounter = 0;
container = null;
termContainer = null;
tabsContainer = null;
},
onFocus() {
const session = sessions.find(s => s.id === activeSession);
if (session) {
requestAnimationFrame(() => {
session.fitAddon.fit();
session.term.focus();
});
}
},
});
})();

399
frontend/js/atlus.js Normal file
View file

@ -0,0 +1,399 @@
/* Atlus — Core shell: app switching, WebSocket stats, panel updates */
(function () {
'use strict';
// =====================================================================
// Auth guard
// =====================================================================
const TOKEN = sessionStorage.getItem('atlus_token');
const USER = sessionStorage.getItem('atlus_user');
if (!TOKEN) {
window.location.href = '/';
return;
}
// =====================================================================
// Globals
// =====================================================================
window.Atlus = {
token: TOKEN,
user: USER,
apps: {}, // registered app modules { id: { init, destroy, title } }
openApps: [], // ordered list of open app ids
activeApp: null, // currently focused app id
layout: 'single', // 'single' | 'split'
secondaryApp: null,
/** Authenticated fetch wrapper */
async apiFetch(url, opts = {}) {
opts.headers = Object.assign({ 'Authorization': `Bearer ${TOKEN}` }, opts.headers || {});
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(url, opts);
if (res.status === 401) {
sessionStorage.clear();
window.location.href = '/';
return;
}
return res;
},
/** Create an authenticated WebSocket URL */
wsUrl(path) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const sep = path.includes('?') ? '&' : '?';
return `${proto}//${location.host}${path}${sep}token=${TOKEN}`;
},
/** Register an app module */
registerApp(id, module) {
this.apps[id] = module;
},
/** Format bytes to human readable */
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
},
};
// =====================================================================
// DOM refs
// =====================================================================
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const stageTabs = $('#stageTabs');
const paneA = $('#paneContentA');
const paneB = $('#paneContentB');
const paneTitleA = $('#paneTitleA');
const paneTitleB = $('#paneTitleB');
const paneBEl = $('#paneB');
const welcomeScreen = $('#welcomeScreen');
// =====================================================================
// App switching
// =====================================================================
function openApp(appId) {
const app = Atlus.apps[appId];
if (!app) return;
// Already open — just focus
if (Atlus.openApps.includes(appId)) {
focusApp(appId);
return;
}
Atlus.openApps.push(appId);
addTab(appId, app.title || appId);
// Create app container
const container = document.createElement('div');
container.className = 'app-view';
container.id = `app-${appId}`;
container.style.display = 'none';
paneA.appendChild(container);
// Initialize app
if (app.init) app.init(container);
focusApp(appId);
}
function focusApp(appId) {
if (welcomeScreen) welcomeScreen.style.display = 'none';
// Hide all apps in pane A
paneA.querySelectorAll('.app-view').forEach(el => el.style.display = 'none');
// Show target
const target = $(`#app-${appId}`);
if (target) target.style.display = 'flex';
// Update tabs
stageTabs.querySelectorAll('.stage-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.app === appId);
});
// Update dock
$$('.dock-item[data-app]').forEach(item => {
item.classList.toggle('active', item.dataset.app === appId);
});
// Update titlebar
const app = Atlus.apps[appId];
paneTitleA.textContent = app ? app.title : appId;
Atlus.activeApp = appId;
// Notify app it got focus
if (app && app.onFocus) app.onFocus();
}
function closeApp(appId) {
const app = Atlus.apps[appId];
if (app && app.destroy) app.destroy();
// Remove DOM
const container = $(`#app-${appId}`);
if (container) container.remove();
// Remove from open list
Atlus.openApps = Atlus.openApps.filter(id => id !== appId);
// Remove tab
const tab = stageTabs.querySelector(`.stage-tab[data-app="${appId}"]`);
if (tab) tab.remove();
// Update dock
const dockItem = $(`.dock-item[data-app="${appId}"]`);
if (dockItem) dockItem.classList.remove('active');
// Focus next app or show welcome
if (Atlus.activeApp === appId) {
if (Atlus.openApps.length > 0) {
focusApp(Atlus.openApps[Atlus.openApps.length - 1]);
} else {
Atlus.activeApp = null;
paneTitleA.textContent = '';
if (welcomeScreen) welcomeScreen.style.display = 'flex';
}
}
}
function addTab(appId, title) {
const tab = document.createElement('button');
tab.className = 'stage-tab';
tab.dataset.app = appId;
tab.innerHTML = `<span>${title}</span><span class="tab-close">&times;</span>`;
tab.addEventListener('click', (e) => {
if (e.target.classList.contains('tab-close')) {
closeApp(appId);
} else {
focusApp(appId);
}
});
stageTabs.appendChild(tab);
}
// =====================================================================
// Dock clicks
// =====================================================================
$$('.dock-item[data-app]').forEach(item => {
item.addEventListener('click', () => openApp(item.dataset.app));
});
// Layout toggle
$$('.layout-btn').forEach(btn => {
btn.addEventListener('click', () => {
const layout = btn.dataset.layout;
Atlus.layout = layout;
$$('.layout-btn').forEach(b => b.classList.toggle('active', b.dataset.layout === layout));
paneBEl.classList.toggle('hidden', layout === 'single');
});
});
// =====================================================================
// System menu
// =====================================================================
const systemMenu = $('#systemMenu');
const logoBtn = $('.dock-logo');
logoBtn.addEventListener('click', () => {
systemMenu.classList.toggle('hidden');
});
systemMenu.addEventListener('click', async (e) => {
const action = e.target.dataset.action;
if (!action) {
// Clicked backdrop
if (e.target === systemMenu) systemMenu.classList.add('hidden');
return;
}
systemMenu.classList.add('hidden');
if (action === 'logout') {
await Atlus.apiFetch('/api/auth/logout', { method: 'POST' });
sessionStorage.clear();
window.location.href = '/';
}
});
// =====================================================================
// Panel — Clock
// =====================================================================
function updateClock() {
const now = new Date();
$('#panelDate').textContent = now.toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric'
});
$('#panelTime').textContent = now.toLocaleTimeString('en-US', {
hour: '2-digit', minute: '2-digit', hour12: false
});
}
updateClock();
setInterval(updateClock, 1000);
// =====================================================================
// Panel — WebSocket stats
// =====================================================================
let statsWs = null;
function connectStats() {
statsWs = new WebSocket(Atlus.wsUrl('/api/stats/ws'));
statsWs.onmessage = (e) => {
const data = JSON.parse(e.data);
updatePanel(data);
};
statsWs.onclose = () => {
setTimeout(connectStats, 3000);
};
statsWs.onerror = () => {
statsWs.close();
};
}
function updatePanel(data) {
// CPU
const cpuPct = Math.round(data.cpu_percent);
$('#statCpu').textContent = cpuPct + '%';
updateBar($('#statCpuBar'), cpuPct);
// Memory
const memPct = Math.round(data.memory.percent);
const memUsed = Atlus.formatBytes(data.memory.used);
const memTotal = Atlus.formatBytes(data.memory.total);
$('#statMem').textContent = `${memUsed} / ${memTotal}`;
updateBar($('#statMemBar'), memPct);
// Disk
const diskPct = Math.round(data.disk.percent);
$('#statDisk').textContent = diskPct + '%';
updateBar($('#statDiskBar'), diskPct);
// Temp
if (data.cpu_temp !== null) {
const temp = Math.round(data.cpu_temp);
$('#statTemp').textContent = temp + '\u00B0C';
// Temp bar: 0-85°C range
const tempPct = Math.min(100, Math.round((temp / 85) * 100));
updateBar($('#statTempBar'), tempPct);
}
// Network
const netContainer = $('#panelNetwork');
netContainer.innerHTML = '';
const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) {
const item = document.createElement('div');
item.className = 'panel-net-item';
item.innerHTML = `
<span class="panel-net-dot ${info.up ? 'up' : 'down'}"></span>
<span class="panel-net-name">${name}</span>
<span class="panel-net-ip">${info.ipv4 || '--'}</span>
`;
netContainer.appendChild(item);
}
}
function updateBar(barEl, percent) {
barEl.style.width = percent + '%';
barEl.className = 'stat-bar-fill';
if (percent >= 90) barEl.classList.add('crit');
else if (percent >= 70) barEl.classList.add('warn');
}
// =====================================================================
// Panel — Hostname
// =====================================================================
async function loadHostname() {
try {
const res = await Atlus.apiFetch('/api/settings/system');
if (res.ok) {
const data = await res.json();
$('#panelHostname').textContent = data.hostname;
}
} catch (e) { /* ignore */ }
}
// =====================================================================
// Panel — Services
// =====================================================================
async function loadPanelServices() {
try {
const cfgRes = await Atlus.apiFetch('/api/settings');
if (!cfgRes.ok) return;
const cfg = await cfgRes.json();
const panelUnits = cfg.panel_services || [];
if (panelUnits.length === 0) {
$('#panelServices').innerHTML = '<div style="color:var(--text-muted);font-size:12px;font-family:var(--font-mono);padding:4px 0;">No services pinned</div>';
return;
}
const container = $('#panelServices');
container.innerHTML = '';
for (const unit of panelUnits) {
const res = await Atlus.apiFetch(`/api/services/${unit}`);
if (!res.ok) continue;
const svc = await res.json();
const isActive = svc.active === 'active';
const name = svc.name || unit.replace('.service', '');
const row = document.createElement('div');
row.className = 'panel-service-row';
row.innerHTML = `
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${unit}"></button>
<span class="service-name">${name}</span>
<button class="service-open" data-unit="${unit}">&nearr;</button>
`;
container.appendChild(row);
}
// Toggle handlers
container.querySelectorAll('.service-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const unit = btn.dataset.unit;
const action = btn.classList.contains('on') ? 'stop' : 'start';
await Atlus.apiFetch('/api/services/action', {
method: 'POST',
body: { unit, action },
});
loadPanelServices();
});
});
// Open handlers
container.querySelectorAll('.service-open').forEach(btn => {
btn.addEventListener('click', () => {
openApp('services');
});
});
} catch (e) { /* ignore */ }
}
// =====================================================================
// Init
// =====================================================================
loadHostname();
loadPanelServices();
connectStats();
// Refresh services panel periodically
setInterval(loadPanelServices, 30000);
// Expose for app modules
window.Atlus.openApp = openApp;
window.Atlus.closeApp = closeApp;
window.Atlus.focusApp = focusApp;
})();

53
frontend/js/auth.js Normal file
View file

@ -0,0 +1,53 @@
/* Atlus — Login screen logic */
(function () {
'use strict';
// Redirect to desktop if already authenticated
const token = sessionStorage.getItem('atlus_token');
if (token) {
window.location.href = '/desktop';
return;
}
const form = document.getElementById('loginForm');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('loginBtn');
const errorEl = document.getElementById('loginError');
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.textContent = '';
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in…';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: usernameInput.value.trim(),
password: passwordInput.value,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Invalid credentials');
}
const data = await res.json();
sessionStorage.setItem('atlus_token', data.token);
sessionStorage.setItem('atlus_user', data.username);
window.location.href = '/desktop';
} catch (err) {
errorEl.textContent = err.message;
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
passwordInput.value = '';
passwordInput.focus();
}
});
usernameInput.focus();
})();

290
frontend/js/keyboard.js Normal file
View file

@ -0,0 +1,290 @@
/* Atlus — Custom on-screen keyboard for terminal */
(function () {
'use strict';
const MODES = {
keys: 'Keys',
fn: 'Fn',
nav: 'Nav',
sym: 'Sym',
};
const QUICK_KEYS = ['$', '#', '>', '-', '_', '.', '/', '|', '~'];
const QWERTY_ROWS = [
['1','2','3','4','5','6','7','8','9','0'],
['q','w','e','r','t','y','u','i','o','p'],
['a','s','d','f','g','h','j','k','l'],
['z','x','c','v','b','n','m'],
];
const SYM_ROWS = [
['!','@','#','$','%','^','&','*','(',')'],
['-','_','=','+','[',']','{','}','\\','|'],
[';',':','\'','"',',','.','/','?','`','~'],
['<','>','©','®','…','§','±','×'],
];
const FN_KEYS = ['F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12'];
const NAV_KEYS = [
{label: '↑', seq: '\x1b[A'},
{label: '↓', seq: '\x1b[B'},
{label: '←', seq: '\x1b[D'},
{label: '→', seq: '\x1b[C'},
{label: 'Home', seq: '\x1b[H'},
{label: 'End', seq: '\x1b[F'},
{label: 'PgUp', seq: '\x1b[5~'},
{label: 'PgDn', seq: '\x1b[6~'},
{label: 'Del', seq: '\x1b[3~'},
{label: 'Ins', seq: '\x1b[2~'},
];
class OnScreenKeyboard {
constructor() {
this.el = null;
this.terminal = null; // set by terminal app
this.mode = 'keys';
this.shift = false;
this.ctrl = false;
this.alt = false;
// States: null=off, 'armed'=one-shot, 'locked'=persistent
this.ctrlState = null;
this.altState = null;
this.shiftState = null;
this.visible = true;
}
create() {
this.el = document.createElement('div');
this.el.className = 'osk';
this._render();
return this.el;
}
toggle() {
this.visible = !this.visible;
if (this.el) this.el.classList.toggle('hidden', !this.visible);
}
setTerminal(term) {
this.terminal = term;
}
_send(data) {
if (!this.terminal) return;
let out = data;
// Apply modifiers
if (this.ctrlState && data.length === 1) {
const code = data.toLowerCase().charCodeAt(0);
if (code >= 97 && code <= 122) {
out = String.fromCharCode(code - 96);
}
} else if (this.altState && data.length === 1) {
out = '\x1b' + data;
}
if (this.shiftState && data.length === 1 && data >= 'a' && data <= 'z') {
out = data.toUpperCase();
}
this.terminal.input(out);
// Clear armed (one-shot) modifiers
if (this.ctrlState === 'armed') { this.ctrlState = null; }
if (this.altState === 'armed') { this.altState = null; }
if (this.shiftState === 'armed') { this.shiftState = null; }
this._updateModifiers();
}
_toggleMod(mod) {
const key = mod + 'State';
if (this[key] === null) {
this[key] = 'armed';
} else if (this[key] === 'armed') {
this[key] = 'locked';
} else {
this[key] = null;
}
this._updateModifiers();
}
_updateModifiers() {
if (!this.el) return;
this.el.querySelectorAll('.osk-key.mod').forEach(key => {
const mod = key.dataset.mod;
if (!mod) return;
const state = this[mod + 'State'];
key.classList.toggle('armed', state === 'armed');
key.classList.toggle('locked', state === 'locked');
});
}
_render() {
this.el.innerHTML = '';
// Quick row
const quick = document.createElement('div');
quick.className = 'osk-quick';
QUICK_KEYS.forEach(k => {
const btn = this._makeKey(k, () => this._send(k));
quick.appendChild(btn);
});
this.el.appendChild(quick);
// Mode tabs
const modes = document.createElement('div');
modes.className = 'osk-modes';
for (const [id, label] of Object.entries(MODES)) {
const tab = document.createElement('button');
tab.className = 'osk-mode-tab' + (id === this.mode ? ' active' : '');
tab.textContent = label;
tab.addEventListener('click', () => {
this.mode = id;
this._render();
});
modes.appendChild(tab);
}
this.el.appendChild(modes);
// Mode content
if (this.mode === 'keys') this._renderQwerty();
else if (this.mode === 'fn') this._renderFn();
else if (this.mode === 'nav') this._renderNav();
else if (this.mode === 'sym') this._renderSym();
}
_makeKey(label, handler, className = '') {
const btn = document.createElement('button');
btn.className = 'osk-key ' + className;
btn.textContent = label;
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
handler();
});
btn.addEventListener('click', (e) => {
e.preventDefault();
handler();
});
return btn;
}
_renderQwerty() {
// Modifier row
const modRow = document.createElement('div');
modRow.className = 'osk-row';
modRow.appendChild(this._makeKey('Esc', () => this._send('\x1b'), 'mod'));
modRow.appendChild(this._makeKey('Tab', () => this._send('\t'), 'mod'));
const ctrlKey = this._makeKey('Ctrl', () => this._toggleMod('ctrl'), 'mod');
ctrlKey.dataset.mod = 'ctrl';
modRow.appendChild(ctrlKey);
const altKey = this._makeKey('Alt', () => this._toggleMod('alt'), 'mod');
altKey.dataset.mod = 'alt';
modRow.appendChild(altKey);
this.el.appendChild(modRow);
// QWERTY rows
QWERTY_ROWS.forEach((row, i) => {
const rowEl = document.createElement('div');
rowEl.className = 'osk-row';
if (i === 3) {
// Shift key
const shiftKey = this._makeKey('Shift', () => this._toggleMod('shift'), 'mod wide');
shiftKey.dataset.mod = 'shift';
rowEl.appendChild(shiftKey);
}
row.forEach(k => {
rowEl.appendChild(this._makeKey(k, () => this._send(k)));
});
if (i === 3) {
rowEl.appendChild(this._makeKey('⌫', () => this._send('\x7f'), 'wide'));
}
this.el.appendChild(rowEl);
});
// Space row
const spaceRow = document.createElement('div');
spaceRow.className = 'osk-row';
spaceRow.appendChild(this._makeKey('Paste', () => this._paste(), 'mod'));
spaceRow.appendChild(this._makeKey('Space', () => this._send(' '), 'space'));
spaceRow.appendChild(this._makeKey('Enter', () => this._send('\r'), 'wide'));
this.el.appendChild(spaceRow);
this._updateModifiers();
}
_renderSym() {
SYM_ROWS.forEach(row => {
const rowEl = document.createElement('div');
rowEl.className = 'osk-row';
row.forEach(k => {
rowEl.appendChild(this._makeKey(k, () => this._send(k)));
});
this.el.appendChild(rowEl);
});
}
_renderFn() {
const scroll = document.createElement('div');
scroll.className = 'osk-fn-scroll';
FN_KEYS.forEach((k, i) => {
const seq = `\x1bO${String.fromCharCode(80 + i)}`;
// F1-F4 use \x1bOP-S, F5+ use \x1b[15~, etc.
const seqs = [
'\x1bOP','\x1bOQ','\x1bOR','\x1bOS',
'\x1b[15~','\x1b[17~','\x1b[18~','\x1b[19~',
'\x1b[20~','\x1b[21~','\x1b[23~','\x1b[24~',
];
scroll.appendChild(this._makeKey(k, () => this._send(seqs[i])));
});
this.el.appendChild(scroll);
// Common ctrl combos
const combos = document.createElement('div');
combos.className = 'osk-row';
const ctrlKeys = [
{label: 'Ctrl+C', seq: '\x03'},
{label: 'Ctrl+D', seq: '\x04'},
{label: 'Ctrl+Z', seq: '\x1a'},
{label: 'Ctrl+A', seq: '\x01'},
{label: 'Ctrl+L', seq: '\x0c'},
{label: 'Ctrl+R', seq: '\x12'},
];
ctrlKeys.forEach(k => {
combos.appendChild(this._makeKey(k.label, () => this._send(k.seq), 'mod'));
});
this.el.appendChild(combos);
}
_renderNav() {
const nav = document.createElement('div');
nav.className = 'osk-nav';
NAV_KEYS.forEach(k => {
nav.appendChild(this._makeKey(k.label, () => this._send(k.seq)));
});
this.el.appendChild(nav);
}
async _paste() {
try {
const text = await navigator.clipboard.readText();
if (text && this.terminal) {
this.terminal.input(text);
}
} catch (e) {
// Clipboard access denied
}
}
}
window.Atlus.keyboard = new OnScreenKeyboard();
})();

135
install.sh Executable file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Atlus installer — targets DietPi, Armbian, Debian, Ubuntu
# Usage: curl -sSL https://raw.githubusercontent.com/YOUR_REPO/atlus/main/install.sh | bash
set -euo pipefail
INSTALL_DIR="/opt/atlus"
CONFIG_DIR="/etc/atlus"
DATA_DIR="/var/lib/atlus"
SERVICE_FILE="/etc/systemd/system/atlus.service"
REPO_URL="https://github.com/YOUR_REPO/atlus.git"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info() { echo -e "\033[1;34m[atlus]\033[0m $*"; }
ok() { echo -e "\033[1;32m[atlus]\033[0m $*"; }
err() { echo -e "\033[1;31m[atlus]\033[0m $*" >&2; }
require_root() {
if [[ $EUID -ne 0 ]]; then
err "This installer must be run as root."
exit 1
fi
}
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_NAME="${PRETTY_NAME:-Linux}"
else
OS_ID="unknown"
OS_NAME="Linux"
fi
info "Detected OS: $OS_NAME"
}
# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------
install_deps() {
info "Installing system dependencies..."
apt-get update -qq
apt-get install -y -qq \
python3 python3-venv python3-dev python3-pip \
libpam0g-dev \
git \
cifs-utils \
> /dev/null 2>&1
ok "System dependencies installed."
}
install_atlus() {
info "Installing Atlus to $INSTALL_DIR..."
if [ -d "$INSTALL_DIR/.git" ]; then
info "Existing installation found — pulling latest..."
cd "$INSTALL_DIR"
git pull --ff-only
else
rm -rf "$INSTALL_DIR"
git clone "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Python virtual environment
info "Setting up Python venv..."
python3 -m venv venv
venv/bin/pip install --upgrade pip -q
venv/bin/pip install -r backend/requirements.txt -q
ok "Python dependencies installed."
}
setup_dirs() {
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
chmod 700 "$DATA_DIR"
}
install_service() {
info "Installing systemd service..."
cp "$INSTALL_DIR/atlus.service" "$SERVICE_FILE"
systemctl daemon-reload
systemctl enable atlus.service
systemctl restart atlus.service
ok "Atlus service installed and started."
}
show_status() {
echo ""
ok "============================="
ok " Atlus installed!"
ok "============================="
echo ""
# Get primary IP
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$IP" ]; then
info "Access Atlus at: http://$IP:7779"
else
info "Access Atlus at: http://$(hostname):7779"
fi
info "Log in with any system user (PAM authentication)."
echo ""
info "Manage the service:"
info " systemctl status atlus"
info " systemctl restart atlus"
info " journalctl -u atlus -f"
echo ""
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
echo ""
echo " _ _ _"
echo " /_\ | |_| |_ _ ___"
echo " / _ \| _| | | | (_-<"
echo " /_/ \_\\\\__|_| \\_,_/__/"
echo ""
echo " Web Desktop Environment for SBCs"
echo ""
require_root
detect_os
install_deps
install_atlus
setup_dirs
install_service
show_status
}
main "$@"

177
package.sh Executable file
View file

@ -0,0 +1,177 @@
#!/usr/bin/env bash
# Build a self-contained Atlus tarball for deployment to SBCs.
# Usage: ./package.sh
# Output: atlus-<version>.tar.gz (scp it to the target, then run the bundled install)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
VERSION=$(date +%Y%m%d-%H%M%S)
STAGING="/tmp/atlus-package-$$"
OUT="${SCRIPT_DIR}/atlus-${VERSION}.tar.gz"
info() { echo -e "\033[1;34m[package]\033[0m $*"; }
ok() { echo -e "\033[1;32m[package]\033[0m $*"; }
cleanup() { rm -rf "$STAGING"; }
trap cleanup EXIT
info "Packaging Atlus..."
# Create staging directory
mkdir -p "$STAGING/atlus"
# Copy application files (exclude dev artifacts)
rsync -a --exclude='.git' \
--exclude='venv' \
--exclude='.claude' \
--exclude='.atlus_data' \
--exclude='.DS_Store' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='atlus-*.tar.gz' \
--exclude='package.sh' \
"$SCRIPT_DIR/" "$STAGING/atlus/"
# Create the deploy script that lives inside the tarball
cat > "$STAGING/deploy.sh" << 'DEPLOY_EOF'
#!/usr/bin/env bash
# Atlus deployment script — run on the target SBC as root
# Usage: sudo ./deploy.sh
set -euo pipefail
INSTALL_DIR="/opt/atlus"
CONFIG_DIR="/etc/atlus"
DATA_DIR="/var/lib/atlus"
SERVICE_FILE="/etc/systemd/system/atlus.service"
info() { echo -e "\033[1;34m[atlus]\033[0m $*"; }
ok() { echo -e "\033[1;32m[atlus]\033[0m $*"; }
err() { echo -e "\033[1;31m[atlus]\033[0m $*" >&2; }
# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
err "This installer must be run as root (use sudo)."
exit 1
fi
echo ""
echo " _ _ _"
echo " /_\ | |_| |_ _ ___"
echo " / _ \| _| | | | (_-<"
echo " /_/ \_\\\\__|_| \\_,_/__/"
echo ""
echo " Web Desktop Environment for SBCs"
echo ""
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -f /etc/os-release ]; then
. /etc/os-release
info "Detected OS: ${PRETTY_NAME:-Linux}"
else
info "Detected OS: Linux"
fi
# ---------------------------------------------------------------------------
# System dependencies
# ---------------------------------------------------------------------------
info "Installing system dependencies..."
apt-get update -qq
apt-get install -y -qq \
python3 python3-venv python3-dev python3-pip \
libpam0g-dev \
cifs-utils \
> /dev/null 2>&1
ok "System dependencies installed."
# ---------------------------------------------------------------------------
# Install application files
# ---------------------------------------------------------------------------
info "Installing Atlus to $INSTALL_DIR..."
# Back up existing config if upgrading
if [ -d "$INSTALL_DIR" ] && [ -d "$CONFIG_DIR" ]; then
info "Existing installation found — upgrading..."
fi
# Copy application files
mkdir -p "$INSTALL_DIR"
rsync -a --delete \
--exclude='venv' \
"$SCRIPT_DIR/atlus/" "$INSTALL_DIR/"
# ---------------------------------------------------------------------------
# Python virtual environment
# ---------------------------------------------------------------------------
info "Setting up Python virtual environment..."
if [ ! -d "$INSTALL_DIR/venv" ]; then
python3 -m venv "$INSTALL_DIR/venv"
fi
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/backend/requirements.txt" -q
ok "Python dependencies installed."
# ---------------------------------------------------------------------------
# Directories and permissions
# ---------------------------------------------------------------------------
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
chmod 700 "$DATA_DIR"
# ---------------------------------------------------------------------------
# systemd service
# ---------------------------------------------------------------------------
info "Installing systemd service..."
cp "$INSTALL_DIR/atlus.service" "$SERVICE_FILE"
systemctl daemon-reload
systemctl enable atlus.service
systemctl restart atlus.service
ok "Atlus service started."
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
ok "============================="
ok " Atlus installed!"
ok "============================="
echo ""
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$IP" ]; then
info "Access Atlus at: http://$IP:7779"
else
info "Access Atlus at: http://$(hostname):7779"
fi
info "Log in with any system user (PAM authentication)."
echo ""
info "Manage the service:"
info " systemctl status atlus"
info " systemctl restart atlus"
info " journalctl -u atlus -f"
echo ""
DEPLOY_EOF
chmod +x "$STAGING/deploy.sh"
# Build tarball
info "Creating tarball..."
tar -czf "$OUT" -C "$STAGING" deploy.sh atlus/
ok "Package built: $OUT"
FILESIZE=$(du -h "$OUT" | cut -f1)
ok "Size: $FILESIZE"
echo ""
info "Deploy to your SBC:"
info " scp $OUT user@your-sbc:~/"
info " ssh user@your-sbc"
info " tar xzf $(basename "$OUT")"
info " sudo ./deploy.sh"
echo ""