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:
commit
f9743bb29a
45 changed files with 6556 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
514
ATLUS_CONTEXT.md
Normal 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
29
LICENSE
Normal 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
29
atlus.service
Normal 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
0
backend/__init__.py
Normal file
131
backend/auth.py
Normal file
131
backend/auth.py
Normal 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
103
backend/config.py
Normal 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
137
backend/main.py
Normal 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
8
backend/requirements.txt
Normal 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
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
267
backend/routers/files.py
Normal file
267
backend/routers/files.py
Normal 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
|
||||
0
backend/routers/plugins/__init__.py
Normal file
0
backend/routers/plugins/__init__.py
Normal file
201
backend/routers/plugins/asi_bridge.py
Normal file
201
backend/routers/plugins/asi_bridge.py
Normal 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")
|
||||
89
backend/routers/processes.py
Normal file
89
backend/routers/processes.py
Normal 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
131
backend/routers/services.py
Normal 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
116
backend/routers/settings.py
Normal 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
121
backend/routers/stats.py
Normal 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
116
backend/routers/terminal.py
Normal 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
0
backend/ws/__init__.py
Normal file
55
backend/ws/manager.py
Normal file
55
backend/ws/manager.py
Normal 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()
|
||||
5
frontend/assets/atlus-logo.svg
Normal file
5
frontend/assets/atlus-logo.svg
Normal 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
254
frontend/css/apps/files.css
Normal 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;
|
||||
}
|
||||
151
frontend/css/apps/services.css
Normal file
151
frontend/css/apps/services.css
Normal 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);
|
||||
}
|
||||
183
frontend/css/apps/settings.css
Normal file
183
frontend/css/apps/settings.css
Normal 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
140
frontend/css/apps/tasks.css
Normal 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);
|
||||
}
|
||||
93
frontend/css/apps/terminal.css
Normal file
93
frontend/css/apps/terminal.css
Normal 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
115
frontend/css/dock.css
Normal 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
138
frontend/css/keyboard.css
Normal 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
214
frontend/css/panel.css
Normal 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
143
frontend/css/shell.css
Normal 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
179
frontend/css/stage.css
Normal 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;
|
||||
}
|
||||
51
frontend/css/variables.css
Normal file
51
frontend/css/variables.css
Normal 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
201
frontend/desktop.html
Normal 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">>_</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
163
frontend/index.html
Normal 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>
|
||||
176
frontend/js/apps/asi_bridge.js
Normal file
176
frontend/js/apps/asi_bridge.js
Normal 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)} ·
|
||||
${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
353
frontend/js/apps/files.js
Normal 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();
|
||||
},
|
||||
});
|
||||
})();
|
||||
179
frontend/js/apps/services.js
Normal file
179
frontend/js/apps/services.js
Normal 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)} ·
|
||||
Total recv: ${Atlus.formatBytes(data.network.bytes_recv)}
|
||||
</div>
|
||||
`;
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {},
|
||||
});
|
||||
})();
|
||||
251
frontend/js/apps/settings.js
Normal file
251
frontend/js/apps/settings.js
Normal 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
163
frontend/js/apps/tasks.js
Normal 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}">×</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 = [];
|
||||
},
|
||||
});
|
||||
})();
|
||||
280
frontend/js/apps/terminal.js
Normal file
280
frontend/js/apps/terminal.js
Normal 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">×</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
399
frontend/js/atlus.js
Normal 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">×</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}">↗</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
53
frontend/js/auth.js
Normal 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
290
frontend/js/keyboard.js
Normal 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
135
install.sh
Executable 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
177
package.sh
Executable 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 ""
|
||||
Loading…
Reference in a new issue