first commit
This commit is contained in:
commit
ab1a42091d
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Copy to .env and adjust
|
||||||
|
DJANGO_SECRET_KEY=dev-insecure-change-me
|
||||||
|
DJANGO_DEBUG=1
|
||||||
|
ALLOWED_HOSTS=127.0.0.1,localhost
|
||||||
|
SSH_KEY_DIR=/home/youruser/.ssh
|
||||||
|
KNOWN_HOSTS_PATH=/home/youruser/.ssh/known_hosts
|
||||||
|
STRICT_HOST_KEY_CHECKING=true
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
db.sqlite3
|
||||||
|
staticfiles/
|
||||||
1
Procfile.dev
Normal file
1
Procfile.dev
Normal file
@ -0,0 +1 @@
|
|||||||
|
web: daphne -b 127.0.0.1 -p 8000 config.asgi:application
|
||||||
259
README.md
Normal file
259
README.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# Remote Admin (Local Dev)
|
||||||
|
|
||||||
|
Lightweight Django + Channels app for live SSH command execution & streaming.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Python 3.12
|
||||||
|
- Django 5.x
|
||||||
|
- Channels 4 (InMemoryChannelLayer, single-process only)
|
||||||
|
- asyncssh for SSH
|
||||||
|
- HTMX + vanilla JS (no build toolchain)
|
||||||
|
- Tailwind via CDN
|
||||||
|
- SQLite
|
||||||
|
|
||||||
|
## Current Feature Set
|
||||||
|
- Hosts: CRUD for SSH target metadata
|
||||||
|
- Tasks: Fully editable reusable command tasks (custom only; static registry removed)
|
||||||
|
- Batch Scripts: Multi-step scripts with `cd` directory persistence and per-step progress
|
||||||
|
- Web Console:
|
||||||
|
- Connect/disconnect via WebSocket
|
||||||
|
- Run ad‑hoc command OR select saved task OR run batch
|
||||||
|
- Real-time stdout/stderr streaming
|
||||||
|
- Batch progress events (STEP X/Y)
|
||||||
|
- Cancel running command/batch (graceful termination)
|
||||||
|
- Validation & UX safeguards:
|
||||||
|
- Empty commands prevented client-side
|
||||||
|
- Batch normalization (strip Windows newlines, trim trailing blanks, ignore comments/blank lines)
|
||||||
|
- Delete confirmations (hx-confirm)
|
||||||
|
- Logs:
|
||||||
|
- Status, exit code, duration, output tail
|
||||||
|
- Run type (`single` / `batch`)
|
||||||
|
- Failed step index (for batch failures / errors)
|
||||||
|
- Structured error events JSON: `{event:"error", type:"ssh|runtime", message:"..."}`
|
||||||
|
- Auth: Login required for all views
|
||||||
|
- Manual: `/manual/` in-app searchable documentation
|
||||||
|
- Tests: CRUD + WebSocket execution (success, failure, batch success/failure, cancel) 13 passing
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # edit values
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
Run (single process ONLY):
|
||||||
|
```bash
|
||||||
|
daphne -b 127.0.0.1 -p 8000 config.asgi:application
|
||||||
|
# or for quick dev
|
||||||
|
python manage.py runserver 0.0.0.0:8000 # fine for dev only
|
||||||
|
```
|
||||||
|
Visit: http://127.0.0.1:8000/
|
||||||
|
|
||||||
|
## Environment Variables (.env)
|
||||||
|
| Var | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| DJANGO_SECRET_KEY | Django secret key |
|
||||||
|
| DJANGO_DEBUG | 0/1 toggle |
|
||||||
|
| ALLOWED_HOSTS | Comma list |
|
||||||
|
| SSH_KEY_DIR | Optional default key base path |
|
||||||
|
| KNOWN_HOSTS_PATH | Path to known_hosts file |
|
||||||
|
| STRICT_HOST_KEY_CHECKING | true/false – enforce host key verification |
|
||||||
|
|
||||||
|
## SSH Setup (DE)
|
||||||
|
Ausführliche Anleitung zur Einrichtung einer funktionierenden SSH-Verbindung für die Web-Konsole.
|
||||||
|
|
||||||
|
### 1. Lokalen SSH-Key prüfen oder erzeugen
|
||||||
|
Prüfen ob bereits ein (modernen) Ed25519 Key existiert:
|
||||||
|
```bash
|
||||||
|
ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub 2>/dev/null || echo 'kein ed25519 key'
|
||||||
|
```
|
||||||
|
Falls nicht vorhanden erstellen:
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -C "$(whoami)@$(hostname)" -N ''
|
||||||
|
```
|
||||||
|
Sichere Rechte setzen:
|
||||||
|
```bash
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
chmod 644 ~/.ssh/id_ed25519.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Public Key auf Zielhost hinterlegen
|
||||||
|
Ersetzt `deploy@server1` durch Benutzer & Host.
|
||||||
|
```bash
|
||||||
|
cat ~/.ssh/id_ed25519.pub | ssh deploy@server1 '\
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
|
||||||
|
cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
|
||||||
|
```
|
||||||
|
Test (direkt):
|
||||||
|
```bash
|
||||||
|
ssh deploy@server1 'echo OK'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Host Key erfassen (bei Strict Checking)
|
||||||
|
Nur nötig wenn `STRICT_HOST_KEY_CHECKING=true` (empfohlen Production).
|
||||||
|
```bash
|
||||||
|
ssh-keyscan -T 5 -t rsa,ecdsa,ed25519 server1 >> ~/.ssh/known_hosts
|
||||||
|
```
|
||||||
|
Bei Key-Änderung (Rotation / Reprovisioning) vorher entfernen:
|
||||||
|
```bash
|
||||||
|
ssh-keygen -R server1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Authentifizierungs-Methode wählen
|
||||||
|
| Methode | Wann nutzen | Voraussetzungen |
|
||||||
|
|---------|-------------|-----------------|
|
||||||
|
| ssh_key | Fester Schlüsselpfad | Privater Key-Dateipfad existiert & Rechte korrekt |
|
||||||
|
| agent | Temporäre Nutzung / Passphrase-Key | `ssh-agent` läuft & `ssh-add` ausgeführt |
|
||||||
|
|
||||||
|
Agent starten (falls nicht aktiv):
|
||||||
|
```bash
|
||||||
|
eval "$(ssh-agent -s)"
|
||||||
|
ssh-add ~/.ssh/id_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Host in der Web-App anlegen
|
||||||
|
Formularfelder:
|
||||||
|
- Name: frei, z.B. `prod-app-1`
|
||||||
|
- Hostname: DNS / IP, z.B. `10.10.10.15`
|
||||||
|
- Port: Standard 22
|
||||||
|
- Username: z.B. `deploy`
|
||||||
|
- Auth Method: `SSH Key` oder `Agent`
|
||||||
|
- Key Path (nur bei SSH Key): Absoluter Pfad, z.B. `/home/deploy/.ssh/id_ed25519`
|
||||||
|
- Strict Host Key Checking: Aktiv lassen außer im lokalen Test-Lab
|
||||||
|
|
||||||
|
### 6. Verbindung testen
|
||||||
|
In der Console:
|
||||||
|
1. Host wählen
|
||||||
|
2. Connect klicken (Status-Badge -> CONNECTED)
|
||||||
|
3. Ad-hoc Command `uname -a` eingeben → Run
|
||||||
|
4. Ausgabe sollte erscheinen; bei Fehlern → Fehlermeldung lesen (structured error event)
|
||||||
|
|
||||||
|
### 7. Typische Fehler & Lösungen
|
||||||
|
| Meldung | Ursache | Lösung |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| Host key not trusted | Host Key fehlt / weicht ab | Key mit `ssh-keyscan` sammeln oder Strict deaktivieren (nur dev) |
|
||||||
|
| Permission denied | Key / User passt nicht | User/Key prüfen, Rechte kontrollieren, ggf. Agent laden |
|
||||||
|
| Connection lost | Netzwerk/Firewall | Direkten SSH Versuch testen, Port offen? |
|
||||||
|
| Unknown action | Falsches WebSocket Payload | Seite neu laden |
|
||||||
|
|
||||||
|
### 8. Beispiel: Zwei Hosts (Prod & Staging)
|
||||||
|
```
|
||||||
|
Name: prod-api Host: prod-api.company.internal User: deploy Auth: ssh_key Key Path: /home/deploy/.ssh/id_ed25519
|
||||||
|
Name: staging-api Host: staging.company.internal User: deploy Auth: agent Key Path: (leer, via ssh-agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Sicherheit
|
||||||
|
- Keine Passwörter im Modell gespeichert
|
||||||
|
- Private Keys verbleiben ausschließlich im Dateisystem
|
||||||
|
- Strict Checking schützt vor Man-in-the-Middle
|
||||||
|
|
||||||
|
> Password Auth ist aktuell deaktiviert – nutze SSH Key oder Agent.
|
||||||
|
|
||||||
|
## Console Usage Overview
|
||||||
|
1. Select host, Connect.
|
||||||
|
2. Choose EITHER a saved task, ad-hoc command, or batch.
|
||||||
|
3. Press Run / Run Batch.
|
||||||
|
4. Observe live output; stderr in red.
|
||||||
|
5. For batch: status badge shows `STEP X/Y`.
|
||||||
|
6. Cancel to terminate; status becomes Canceling then Canceled.
|
||||||
|
7. On completion badge shows exit code.
|
||||||
|
8. View history under Logs.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
Custom tasks only (registry removed). Fields:
|
||||||
|
- name (unique key)
|
||||||
|
- label (display)
|
||||||
|
- command (shell executed exactly as typed)
|
||||||
|
- description (optional)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Name: restart_app
|
||||||
|
Label: Restart App Service
|
||||||
|
Command: sudo systemctl restart app.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Scripts
|
||||||
|
One line per step. Rules:
|
||||||
|
- Blank lines & lines starting with `#` ignored.
|
||||||
|
- `cd path` lines set persistent working directory for subsequent steps.
|
||||||
|
- On failure (non-zero exit) batch stops; failed step recorded.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
# Deploy
|
||||||
|
cd /srv/app
|
||||||
|
./stop.sh
|
||||||
|
./deploy.sh
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structured Events (WebSocket)
|
||||||
|
| Event | Payload Fields | Notes |
|
||||||
|
|-------|----------------|-------|
|
||||||
|
| connected | session | Initial handshake |
|
||||||
|
| started | log_id, command | Run started |
|
||||||
|
| chunk | stream, data | Output chunk (stdout/stderr) |
|
||||||
|
| progress | current, total, step | Batch step progress |
|
||||||
|
| canceling | | Cancel requested |
|
||||||
|
| completed | status, exit_code | Terminal status |
|
||||||
|
| error | type, message | type = ssh | runtime |
|
||||||
|
|
||||||
|
Status -> final badge mapping:
|
||||||
|
- ok -> green
|
||||||
|
- failed -> red (shows failed step when batch)
|
||||||
|
- error -> red (darker)
|
||||||
|
- canceled -> yellow
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
Fields now include:
|
||||||
|
- run_type (single|batch)
|
||||||
|
- failed_step (nullable)
|
||||||
|
- duration (derived) & timestamps
|
||||||
|
- output_tail (last 32K)
|
||||||
|
|
||||||
|
## Manual
|
||||||
|
Accessible at `/manual/` or nav link “Manual” – contains full user guide & examples (browser searchable).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
```bash
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
Coverage includes model CRUD and WebSocket execution scenarios (success, fail, batch fail, cancel).
|
||||||
|
|
||||||
|
## Troubleshooting (Quick Table)
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| Host key not trusted | Missing known_hosts entry | ssh-keyscan >> known_hosts or disable (dev) |
|
||||||
|
| Permission denied | Wrong user/key / permissions | Fix key path, permissions, agent add |
|
||||||
|
| Invalid task | Name not found | Refresh tasks, re-create |
|
||||||
|
| Batch stops early | Non-zero exit | Inspect failed step in log, tail output |
|
||||||
|
| No output | Command buffers | Use unbuffered flags (e.g. python -u) |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
- Keys referenced by path only; never stored or uploaded.
|
||||||
|
- Use strict host key checking in production.
|
||||||
|
- Single-process Channels (in-memory layer) – not for multi-worker production.
|
||||||
|
|
||||||
|
## Roadmap / Future Ideas
|
||||||
|
- Redis channel layer for scaling
|
||||||
|
- Parameterized tasks (templated inputs)
|
||||||
|
- Role-based access control
|
||||||
|
- Stream-to-disk full log archiving
|
||||||
|
- ANSI color pass-through
|
||||||
|
|
||||||
|
## Development Aids
|
||||||
|
Formatting / lint:
|
||||||
|
```bash
|
||||||
|
ruff check .
|
||||||
|
black .
|
||||||
|
```
|
||||||
|
|
||||||
|
Tailwind is loaded via CDN (see `remotectl/base.html`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
15
manage.py
Normal file
15
manage.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def main():
|
||||||
|
project_dir = Path(__file__).resolve().parent / 'project'
|
||||||
|
if str(project_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(project_dir))
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.config.settings')
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
0
project/__init__.py
Normal file
0
project/__init__.py
Normal file
0
project/config/__init__.py
Normal file
0
project/config/__init__.py
Normal file
22
project/config/asgi.py
Normal file
22
project/config/asgi.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from channels.auth import AuthMiddlewareStack
|
||||||
|
|
||||||
|
project_dir = Path(__file__).resolve().parent.parent
|
||||||
|
if str(project_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(project_dir))
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.config.settings')
|
||||||
|
|
||||||
|
http_app = get_asgi_application()
|
||||||
|
|
||||||
|
# Lazy import after Django setup
|
||||||
|
from project.config import routing # noqa: E402
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
'http': http_app,
|
||||||
|
'websocket': AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
|
||||||
|
})
|
||||||
6
project/config/routing.py
Normal file
6
project/config/routing.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from project.remotectl import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path('ws/ssh/<uuid:session_id>/stream/', consumers.SSHStreamConsumer.as_asgi()),
|
||||||
|
]
|
||||||
85
project/config/settings.py
Normal file
85
project/config/settings.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from django.core.management.utils import get_random_secret_key
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.utils import CryptographyDeprecationWarning # type: ignore
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
class CryptographyDeprecationWarning(Warning):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Suppress noisy crypto algorithm deprecation warnings from asyncssh (ARC4, 3DES)
|
||||||
|
warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', get_random_secret_key())
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1'
|
||||||
|
ALLOWED_HOSTS = [h.strip() for h in os.getenv('ALLOWED_HOSTS', '127.0.0.1,localhost').split(',') if h.strip()]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'channels', 'project.remotectl', ]
|
||||||
|
|
||||||
|
MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'project.config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'project' / 'remotectl' / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'project.config.wsgi.application'
|
||||||
|
ASGI_APPLICATION = 'project.config.asgi.application'
|
||||||
|
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'project' / 'remotectl' / 'static']
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
LOGIN_URL = '/accounts/login/'
|
||||||
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
LOGOUT_REDIRECT_URL = '/accounts/login/'
|
||||||
|
|
||||||
|
SSH_KEY_DIR = os.getenv('SSH_KEY_DIR') or str(Path.home() / '.ssh')
|
||||||
|
KNOWN_HOSTS_PATH = os.getenv('KNOWN_HOSTS_PATH') or str(Path.home() / '.ssh' / 'known_hosts')
|
||||||
|
STRICT_HOST_KEY_CHECKING = os.getenv('STRICT_HOST_KEY_CHECKING', 'true').lower() == 'true'
|
||||||
10
project/config/urls.py
Normal file
10
project/config/urls.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')),
|
||||||
|
path('', include('project.remotectl.urls')),
|
||||||
|
path('', RedirectView.as_view(pattern_name='console', permanent=False)),
|
||||||
|
]
|
||||||
11
project/config/wsgi.py
Normal file
11
project/config/wsgi.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
project_dir = Path(__file__).resolve().parent.parent
|
||||||
|
if str(project_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(project_dir))
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.config.settings')
|
||||||
|
application = get_wsgi_application()
|
||||||
0
project/remotectl/__init__.py
Normal file
0
project/remotectl/__init__.py
Normal file
13
project/remotectl/admin.py
Normal file
13
project/remotectl/admin.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import RemoteHost, CommandLog
|
||||||
|
|
||||||
|
@admin.register(RemoteHost)
|
||||||
|
class RemoteHostAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name","hostname","username","auth_method","strict_host_key_checking")
|
||||||
|
search_fields = ("name","hostname","username")
|
||||||
|
|
||||||
|
@admin.register(CommandLog)
|
||||||
|
class CommandLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id","host","command","status","exit_code","started_at","finished_at")
|
||||||
|
list_filter = ("status",)
|
||||||
|
search_fields = ("command",)
|
||||||
5
project/remotectl/apps.py
Normal file
5
project/remotectl/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class RemoteCtlConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'project.remotectl'
|
||||||
230
project/remotectl/consumers.py
Normal file
230
project/remotectl/consumers.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import RemoteHost, CommandLog, BatchScript, CommandTask
|
||||||
|
from .services.ssh_client import open_connection, run_command, SSHError
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class SSHStreamConsumer(AsyncJsonWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
# ensure attributes exist even if auth fails
|
||||||
|
self.run_task = None
|
||||||
|
self.conn = None
|
||||||
|
self.cancel_event = asyncio.Event()
|
||||||
|
self.log_id = None
|
||||||
|
if not self.scope['user'].is_authenticated:
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
self.session_id = self.scope['url_route']['kwargs'].get('session_id')
|
||||||
|
self.group_name = f"ssh_session_{self.session_id}"
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
await self.send_json({'event':'connected','session':str(self.session_id)})
|
||||||
|
|
||||||
|
async def disconnect(self, close_code): # noqa: D401
|
||||||
|
run_task = getattr(self, 'run_task', None)
|
||||||
|
if run_task and not run_task.done():
|
||||||
|
self.cancel_event.set()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(run_task, timeout=3)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
if getattr(self, 'conn', None):
|
||||||
|
self.conn.close()
|
||||||
|
if hasattr(self, 'group_name'):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive_json(self, content, **kwargs): # noqa: D401
|
||||||
|
action = content.get('action')
|
||||||
|
if action == 'start':
|
||||||
|
if self.run_task and not self.run_task.done():
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'Command already running'})
|
||||||
|
return
|
||||||
|
host_id = content.get('host_id')
|
||||||
|
command = content.get('command')
|
||||||
|
task_key = content.get('task_key')
|
||||||
|
if task_key and not command:
|
||||||
|
task = await self.get_db_task(task_key)
|
||||||
|
if not task:
|
||||||
|
await self.send_json({'event':'error','message':'Invalid task'})
|
||||||
|
return
|
||||||
|
command = task.command
|
||||||
|
if not command:
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'No command provided'})
|
||||||
|
return
|
||||||
|
host = await self.get_host(host_id)
|
||||||
|
if not host:
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'Host not found'})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.conn = await open_connection(host)
|
||||||
|
except SSHError as e:
|
||||||
|
await self.send_json({'event':'error','type':'ssh','message':str(e)})
|
||||||
|
return
|
||||||
|
log = await self.create_log(host, command, run_type='single')
|
||||||
|
self.log_id = log.id
|
||||||
|
self.cancel_event = asyncio.Event()
|
||||||
|
self.run_task = asyncio.create_task(self._run_and_stream(log.id, command))
|
||||||
|
await self.send_json({'event':'started','log_id':log.id,'command':command})
|
||||||
|
elif action == 'start_batch':
|
||||||
|
if self.run_task and not self.run_task.done():
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'Command already running'})
|
||||||
|
return
|
||||||
|
host_id = content.get('host_id')
|
||||||
|
batch_id = content.get('batch_id')
|
||||||
|
host = await self.get_host(host_id)
|
||||||
|
if not host:
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'Host not found'})
|
||||||
|
return
|
||||||
|
batch = await self.get_batch(batch_id)
|
||||||
|
if not batch:
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'Batch not found'})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.conn = await open_connection(host)
|
||||||
|
except SSHError as e:
|
||||||
|
await self.send_json({'event':'error','type':'ssh','message':str(e)})
|
||||||
|
return
|
||||||
|
log = await self.create_log(host, f"BATCH:{batch.name}\n{batch.script}", run_type='batch')
|
||||||
|
self.log_id = log.id
|
||||||
|
self.cancel_event = asyncio.Event()
|
||||||
|
self.run_task = asyncio.create_task(self._run_batch_and_stream(log.id, batch))
|
||||||
|
await self.send_json({'event':'started','log_id':log.id,'command':f'BATCH {batch.name}'})
|
||||||
|
elif action == 'cancel':
|
||||||
|
if self.run_task and not self.run_task.done():
|
||||||
|
self.cancel_event.set()
|
||||||
|
await self.send_json({'event':'canceling'})
|
||||||
|
elif action == 'disconnect':
|
||||||
|
await self.close()
|
||||||
|
else:
|
||||||
|
await self.send_json({'event':'error','type':'runtime','message':'Unknown action'})
|
||||||
|
|
||||||
|
async def _run_and_stream(self, log_id: int, command: str):
|
||||||
|
tail_buf = {'data': ''}
|
||||||
|
|
||||||
|
async def on_chunk(stream_name, data):
|
||||||
|
tail_buf['data'] = (tail_buf['data'] + data)[-32768:]
|
||||||
|
await self.channel_layer.group_send(self.group_name, {
|
||||||
|
'type':'ssh.message',
|
||||||
|
'payload': {'event':'chunk','stream':stream_name,'data':data}
|
||||||
|
})
|
||||||
|
status = 'ok'
|
||||||
|
exit_code = None
|
||||||
|
try:
|
||||||
|
exit_code = await run_command(self.conn, command, on_chunk, self.cancel_event)
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
status = 'canceled'
|
||||||
|
elif exit_code != 0:
|
||||||
|
status = 'failed'
|
||||||
|
except SSHError as e:
|
||||||
|
status = 'error'
|
||||||
|
await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'error','type':'ssh','message':str(e)}})
|
||||||
|
finally:
|
||||||
|
await self.update_log(log_id, status, exit_code, tail_buf['data'])
|
||||||
|
await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'completed','status':status,'exit_code':exit_code}})
|
||||||
|
if self.conn:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
async def _run_batch_and_stream(self, log_id: int, batch: BatchScript):
|
||||||
|
tail_buf = {'data': ''}
|
||||||
|
lines = [ln.rstrip('\n') for ln in batch.script.splitlines()]
|
||||||
|
raw_commands = [l for l in lines if l.strip() and not l.strip().startswith('#')]
|
||||||
|
steps = []
|
||||||
|
current_dir = None
|
||||||
|
for line in raw_commands:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.lower().startswith('cd '):
|
||||||
|
current_dir = stripped[3:].strip()
|
||||||
|
steps.append((stripped, None))
|
||||||
|
else:
|
||||||
|
cmd = stripped
|
||||||
|
if current_dir:
|
||||||
|
cmd = f"cd {shlex.quote(current_dir)} && {cmd}"
|
||||||
|
steps.append((stripped, cmd))
|
||||||
|
|
||||||
|
async def emit(stream, data):
|
||||||
|
tail_buf['data'] = (tail_buf['data'] + data)[-32768:]
|
||||||
|
await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'chunk','stream':stream,'data':data}})
|
||||||
|
|
||||||
|
status = 'ok'
|
||||||
|
overall_exit = 0
|
||||||
|
total = len(steps)
|
||||||
|
failed_step_index = None
|
||||||
|
try:
|
||||||
|
for idx, (display_cmd, exec_cmd) in enumerate(steps, start=1):
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
status = 'canceled'
|
||||||
|
break
|
||||||
|
# send progress event
|
||||||
|
await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'progress','current':idx,'total':total,'step':display_cmd}})
|
||||||
|
await emit('stdout', f"\n>>> [{idx}/{total}] {display_cmd}\n")
|
||||||
|
if exec_cmd:
|
||||||
|
try:
|
||||||
|
ec = await run_command(self.conn, exec_cmd, lambda s,d: emit(s,d), self.cancel_event)
|
||||||
|
except SSHError as e:
|
||||||
|
await emit('stderr', f"ERROR: {e}\n")
|
||||||
|
status = 'error'
|
||||||
|
failed_step_index = idx
|
||||||
|
break
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
status = 'canceled'
|
||||||
|
break
|
||||||
|
if ec != 0:
|
||||||
|
overall_exit = ec
|
||||||
|
status = 'failed'
|
||||||
|
failed_step_index = idx
|
||||||
|
await emit('stderr', f"Step failed with exit code {ec}, aborting batch.\n")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
overall_exit = 0
|
||||||
|
finally:
|
||||||
|
await self.update_log(log_id, status, overall_exit, tail_buf['data'], failed_step_index)
|
||||||
|
await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'completed','status':status,'exit_code':overall_exit}})
|
||||||
|
if self.conn:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
async def ssh_message(self, event):
|
||||||
|
# Handler required for group_send events
|
||||||
|
await self.send_json(event['payload'])
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_host(self, host_id):
|
||||||
|
if not host_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return RemoteHost.objects.get(id=host_id)
|
||||||
|
except RemoteHost.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def create_log(self, host, command: str, run_type: str):
|
||||||
|
return CommandLog.objects.create(host=host, command=command, created_by=self.scope['user'], run_type=run_type)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def update_log(self, log_id: int, status: str, exit_code, tail: str, failed_step: int | None = None):
|
||||||
|
try:
|
||||||
|
log = CommandLog.objects.get(id=log_id)
|
||||||
|
except CommandLog.DoesNotExist: # pragma: no cover
|
||||||
|
return
|
||||||
|
log.mark_finished(status=status, exit_code=exit_code, tail=tail, failed_step=failed_step)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_batch(self, batch_id):
|
||||||
|
if not batch_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return BatchScript.objects.get(id=batch_id)
|
||||||
|
except BatchScript.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_db_task(self, name: str):
|
||||||
|
try:
|
||||||
|
return CommandTask.objects.get(name=name)
|
||||||
|
except CommandTask.DoesNotExist:
|
||||||
|
return None
|
||||||
27
project/remotectl/forms.py
Normal file
27
project/remotectl/forms.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import RemoteHost, BatchScript, CommandTask
|
||||||
|
|
||||||
|
class RemoteHostForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = RemoteHost
|
||||||
|
fields = ['name','hostname','port','username','auth_method','key_path','strict_host_key_checking','notes']
|
||||||
|
widgets = {
|
||||||
|
'notes': forms.Textarea(attrs={'rows':3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
class BatchScriptForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = BatchScript
|
||||||
|
fields = ['name','description','script']
|
||||||
|
widgets = {
|
||||||
|
'script': forms.Textarea(attrs={'rows':14, 'spellcheck':'false'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandTaskForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CommandTask
|
||||||
|
fields = ['name','label','command','description']
|
||||||
|
widgets = {
|
||||||
|
'command': forms.Textarea(attrs={'rows':6,'spellcheck':'false','class':'font-mono'}),
|
||||||
|
'description': forms.Textarea(attrs={'rows':2}),
|
||||||
|
}
|
||||||
103
project/remotectl/migrations/0001_initial.py
Normal file
103
project/remotectl/migrations/0001_initial.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2025-10-14 12:55
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RemoteHost",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("hostname", models.CharField(max_length=255)),
|
||||||
|
("port", models.PositiveIntegerField(default=22)),
|
||||||
|
("username", models.CharField(max_length=100)),
|
||||||
|
(
|
||||||
|
"auth_method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("ssh_key", "SSH Key"),
|
||||||
|
("agent", "SSH Agent"),
|
||||||
|
("password", "Password"),
|
||||||
|
],
|
||||||
|
default="ssh_key",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("key_path", models.CharField(blank=True, max_length=500)),
|
||||||
|
("strict_host_key_checking", models.BooleanField(default=True)),
|
||||||
|
("notes", models.TextField(blank=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CommandLog",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("command", models.TextField()),
|
||||||
|
("args_json", models.JSONField(blank=True, null=True)),
|
||||||
|
("started_at", models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("running", "Running"),
|
||||||
|
("ok", "Success"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("canceled", "Canceled"),
|
||||||
|
("error", "Error"),
|
||||||
|
],
|
||||||
|
default="running",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("exit_code", models.IntegerField(blank=True, null=True)),
|
||||||
|
("output_tail", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"host",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="command_logs",
|
||||||
|
to="remotectl.remotehost",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-started_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
49
project/remotectl/migrations/0002_batchscript.py
Normal file
49
project/remotectl/migrations/0002_batchscript.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2025-10-15 09:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("remotectl", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BatchScript",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100, unique=True)),
|
||||||
|
("description", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"script",
|
||||||
|
models.TextField(
|
||||||
|
help_text="One command per line. Lines starting with # are ignored."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
48
project/remotectl/migrations/0003_commandtask.py
Normal file
48
project/remotectl/migrations/0003_commandtask.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2025-10-15 11:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("remotectl", "0002_batchscript"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CommandTask",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(help_text="Key used internally", max_length=100, unique=True),
|
||||||
|
),
|
||||||
|
("label", models.CharField(max_length=150)),
|
||||||
|
("command", models.TextField()),
|
||||||
|
("description", models.CharField(blank=True, max_length=255)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2025-10-15 12:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("remotectl", "0003_commandtask"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="commandlog",
|
||||||
|
name="failed_step",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="commandlog",
|
||||||
|
name="run_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("single", "Single"), ("batch", "Batch")], default="single", max_length=10
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
project/remotectl/migrations/__init__.py
Normal file
0
project/remotectl/migrations/__init__.py
Normal file
118
project/remotectl/models.py
Normal file
118
project/remotectl/models.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Remove eager evaluation to avoid accessing settings before configured
|
||||||
|
# User = get_user_model()
|
||||||
|
|
||||||
|
def get_user_model_cached():
|
||||||
|
return get_user_model()
|
||||||
|
|
||||||
|
class RemoteHost(models.Model):
|
||||||
|
AUTH_METHOD_CHOICES = [
|
||||||
|
('ssh_key', 'SSH Key'),
|
||||||
|
('agent', 'SSH Agent'),
|
||||||
|
('password', 'Password'), # not stored; prompt in future
|
||||||
|
]
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
hostname = models.CharField(max_length=255)
|
||||||
|
port = models.PositiveIntegerField(default=22)
|
||||||
|
username = models.CharField(max_length=100)
|
||||||
|
auth_method = models.CharField(max_length=20, choices=AUTH_METHOD_CHOICES, default='ssh_key')
|
||||||
|
key_path = models.CharField(max_length=500, blank=True)
|
||||||
|
strict_host_key_checking = models.BooleanField(default=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.username}@{self.hostname})"
|
||||||
|
|
||||||
|
class CommandLog(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('running', 'Running'),
|
||||||
|
('ok', 'Success'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('canceled', 'Canceled'),
|
||||||
|
('error', 'Error'),
|
||||||
|
]
|
||||||
|
RUN_TYPE_CHOICES = [
|
||||||
|
('single','Single'),
|
||||||
|
('batch','Batch'),
|
||||||
|
]
|
||||||
|
host = models.ForeignKey(RemoteHost, on_delete=models.CASCADE, related_name='command_logs')
|
||||||
|
created_by = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
|
command = models.TextField()
|
||||||
|
args_json = models.JSONField(null=True, blank=True)
|
||||||
|
run_type = models.CharField(max_length=10, choices=RUN_TYPE_CHOICES, default='single')
|
||||||
|
failed_step = models.IntegerField(null=True, blank=True)
|
||||||
|
started_at = models.DateTimeField(default=timezone.now)
|
||||||
|
finished_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='running')
|
||||||
|
exit_code = models.IntegerField(null=True, blank=True)
|
||||||
|
output_tail = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-started_at']
|
||||||
|
|
||||||
|
def mark_finished(self, status: str, exit_code: int | None, tail: str | None, failed_step: int | None = None):
|
||||||
|
self.status = status
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.failed_step = failed_step
|
||||||
|
self.finished_at = timezone.now()
|
||||||
|
if tail is not None:
|
||||||
|
self.output_tail = tail
|
||||||
|
self.save(update_fields=['status', 'exit_code', 'failed_step', 'finished_at', 'output_tail'])
|
||||||
|
|
||||||
|
def duration(self): # pragma: no cover simple helper
|
||||||
|
end = self.finished_at or timezone.now()
|
||||||
|
return (end - self.started_at).total_seconds()
|
||||||
|
|
||||||
|
class BatchScript(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
description = models.CharField(max_length=255, blank=True)
|
||||||
|
script = models.TextField(help_text="One command per line. Lines starting with # are ignored.")
|
||||||
|
created_by = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self): # pragma: no cover simple
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def clean(self): # normalize script formatting
|
||||||
|
if self.script:
|
||||||
|
normalized = self.script.replace('\r\n', '\n').replace('\r', '\n')
|
||||||
|
lines = [ln.rstrip() for ln in normalized.split('\n')]
|
||||||
|
# remove trailing empty lines
|
||||||
|
while lines and not lines[-1].strip():
|
||||||
|
lines.pop()
|
||||||
|
self.script = '\n'.join(lines).strip('\n')
|
||||||
|
|
||||||
|
def step_lines(self): # returns list of effective lines excluding comments/blank
|
||||||
|
if not self.script:
|
||||||
|
return []
|
||||||
|
return [l for l in self.script.split('\n') if l.strip() and not l.strip().startswith('#')]
|
||||||
|
|
||||||
|
def step_count(self): # pragma: no cover trivial
|
||||||
|
return len(self.step_lines())
|
||||||
|
|
||||||
|
class CommandTask(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True, help_text="Key used internally")
|
||||||
|
label = models.CharField(max_length=150)
|
||||||
|
command = models.TextField()
|
||||||
|
description = models.CharField(max_length=255, blank=True)
|
||||||
|
created_by = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self): # pragma: no cover
|
||||||
|
return self.label or self.name
|
||||||
0
project/remotectl/services/__init__.py
Normal file
0
project/remotectl/services/__init__.py
Normal file
100
project/remotectl/services/ssh_client.py
Normal file
100
project/remotectl/services/ssh_client.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
import asyncssh
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
CHUNK_LIMIT = 32768 # store last N chars for tail
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SSHError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def open_connection(remote_host):
|
||||||
|
"""Open an SSH connection honoring host settings.
|
||||||
|
Adds diagnostics & graceful fallback if key file missing.
|
||||||
|
"""
|
||||||
|
if remote_host.strict_host_key_checking and settings.STRICT_HOST_KEY_CHECKING:
|
||||||
|
known_hosts = os.path.expanduser(settings.KNOWN_HOSTS_PATH)
|
||||||
|
else:
|
||||||
|
known_hosts = None # disables host key verification (dev only)
|
||||||
|
|
||||||
|
client_keys = None
|
||||||
|
agent_fwd = False
|
||||||
|
|
||||||
|
if remote_host.auth_method == 'ssh_key':
|
||||||
|
key_paths = []
|
||||||
|
if remote_host.key_path:
|
||||||
|
kp = os.path.expanduser(remote_host.key_path)
|
||||||
|
if os.path.exists(kp):
|
||||||
|
key_paths.append(kp)
|
||||||
|
logger.info("Using explicit SSH key: %s", kp)
|
||||||
|
else:
|
||||||
|
logger.warning("Configured key_path does not exist: %s (falling back to default keys / agent)", kp)
|
||||||
|
# If no valid explicit key, allow asyncssh to try default keys & agent
|
||||||
|
client_keys = key_paths or None
|
||||||
|
elif remote_host.auth_method == 'agent':
|
||||||
|
agent_fwd = True
|
||||||
|
client_keys = None # rely on agent
|
||||||
|
logger.info("Using SSH agent for auth (SSH_AUTH_SOCK=%s)", os.environ.get('SSH_AUTH_SOCK'))
|
||||||
|
elif remote_host.auth_method == 'password':
|
||||||
|
raise SSHError("Password auth not implemented – bitte SSH Key oder Agent nutzen")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Opening SSH connection to %s, port %s (known_hosts=%s, client_keys=%s, agent=%s)",
|
||||||
|
remote_host.hostname, remote_host.port, known_hosts if known_hosts else 'DISABLED', client_keys, agent_fwd
|
||||||
|
)
|
||||||
|
return await asyncssh.connect(
|
||||||
|
host=remote_host.hostname,
|
||||||
|
port=remote_host.port,
|
||||||
|
username=remote_host.username,
|
||||||
|
known_hosts=known_hosts,
|
||||||
|
client_keys=client_keys,
|
||||||
|
agent_forwarding=agent_fwd,
|
||||||
|
)
|
||||||
|
except asyncssh.HostKeyNotVerifiable as e:
|
||||||
|
raise SSHError(f"Host key not trusted for host {remote_host.hostname}") from e
|
||||||
|
except asyncssh.PermissionDenied as e:
|
||||||
|
raise SSHError(f"Permission denied for user {remote_host.username} on host {remote_host.hostname}") from e
|
||||||
|
except (asyncssh.DisconnectError, asyncssh.ConnectionLost) as e:
|
||||||
|
raise SSHError(f"Connection lost: {e}") from e
|
||||||
|
except (asyncssh.Error, OSError) as e:
|
||||||
|
raise SSHError(str(e)) from e
|
||||||
|
|
||||||
|
async def run_command(conn: asyncssh.SSHClientConnection, command: str, on_chunk: Callable[[str,str], Awaitable[None]], cancel_event: asyncio.Event) -> int:
|
||||||
|
try:
|
||||||
|
proc = await conn.create_process(command)
|
||||||
|
except (asyncssh.Error, OSError) as e:
|
||||||
|
raise SSHError(str(e)) from e
|
||||||
|
|
||||||
|
tail_buf = ''
|
||||||
|
|
||||||
|
async def reader(stream, stream_name):
|
||||||
|
nonlocal tail_buf
|
||||||
|
async for line in stream:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
break
|
||||||
|
data = line.rstrip('\n')
|
||||||
|
tail_buf = (tail_buf + data + '\n')[-CHUNK_LIMIT:]
|
||||||
|
await on_chunk(stream_name, data + '\n')
|
||||||
|
|
||||||
|
stdout_task = asyncio.create_task(reader(proc.stdout, 'stdout'))
|
||||||
|
stderr_task = asyncio.create_task(reader(proc.stderr, 'stderr'))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait_closed(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
return 130 # typical canceled code
|
||||||
|
if proc.exit_status is not None:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
await stdout_task
|
||||||
|
await stderr_task
|
||||||
|
return proc.exit_status
|
||||||
38
project/remotectl/services/tasks.py
Normal file
38
project/remotectl/services/tasks.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Simple predefined tasks registry
|
||||||
|
# key -> metadata (label, command template, description)
|
||||||
|
# Command template can use {param} formatting; future extension with parameters
|
||||||
|
|
||||||
|
TASKS = {
|
||||||
|
'deploy_app': {
|
||||||
|
'label': 'Deploy App',
|
||||||
|
'command': 'cd /srv/app && ./deploy.sh',
|
||||||
|
'description': 'Run application deployment script.'
|
||||||
|
},
|
||||||
|
'restart_ssh': {
|
||||||
|
'label': 'Restart SSH Service',
|
||||||
|
'command': 'sudo systemctl restart ssh',
|
||||||
|
'description': 'Restart the SSH daemon.'
|
||||||
|
},
|
||||||
|
'status_app': {
|
||||||
|
'label': 'status App',
|
||||||
|
'command': 'sudo systemctl status pipeline@pyapp-dali',
|
||||||
|
'description': 'Run status.'
|
||||||
|
},
|
||||||
|
'start_app': {
|
||||||
|
'label': 'start App',
|
||||||
|
'command': 'sudo systemctl start pipeline@pyapp-dali',
|
||||||
|
'description': 'Run start application '
|
||||||
|
},
|
||||||
|
'stop_app': {
|
||||||
|
'label': 'stop App',
|
||||||
|
'command': 'sudo systemctl stop pipeline@pyapp-dali',
|
||||||
|
'description': 'Run stop application '
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_tasks():
|
||||||
|
return TASKS
|
||||||
|
|
||||||
|
def get_task(key: str):
|
||||||
|
return TASKS.get(key)
|
||||||
1
project/remotectl/static/remotectl/app.js
Normal file
1
project/remotectl/static/remotectl/app.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Placeholder for future JS (ANSI parsing, etc.)
|
||||||
20
project/remotectl/templates/registration/logged_out.html
Normal file
20
project/remotectl/templates/registration/logged_out.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Logout</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 text-gray-900 dark:text-gray-100">
|
||||||
|
<div class="max-w-md mx-auto bg-white dark:bg-gray-800 p-6 rounded-lg shadow space-y-4">
|
||||||
|
<h1 class="text-xl font-semibold">Abgemeldet</h1>
|
||||||
|
<p class="text-sm">Du bist jetzt abgemeldet.</p>
|
||||||
|
<a href="/accounts/login/"
|
||||||
|
class="inline-block px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">Erneut anmelden</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
35
project/remotectl/templates/registration/login.html
Normal file
35
project/remotectl/templates/registration/login.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{% load remotectl_extras %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Login</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body
|
||||||
|
class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-6 text-gray-900 dark:text-gray-100">
|
||||||
|
<div class="w-full max-w-sm bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
|
||||||
|
<h1 class="text-xl font-semibold mb-5">Login</h1>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="mb-4 text-sm text-red-600">Ungültige Zugangsdaten.</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide">Benutzername</label>
|
||||||
|
{{ form.username|add_class:"w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide">Passwort</label>
|
||||||
|
{{ form.password|add_class:"w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" }}
|
||||||
|
</div>
|
||||||
|
{% if next %}<input type="hidden" name="next" value="{{ next }}" />{% endif %}
|
||||||
|
<button class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded font-medium">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
39
project/remotectl/templates/remotectl/base.html
Normal file
39
project/remotectl/templates/remotectl/base.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full" x-data="{dark: localStorage.getItem('dark')==='1'}" x-bind:class="dark ? 'dark' : ''"
|
||||||
|
xmlns:x="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>{% block title %}Remote Admin{% endblock %}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-gray-100 text-gray-900">
|
||||||
|
<nav class="bg-gray-800 text-white p-3 flex gap-4">
|
||||||
|
<a href="/" class="font-semibold">Console</a>
|
||||||
|
<a href="/hosts/">Hosts</a>
|
||||||
|
<a href="/logs/">Logs</a>
|
||||||
|
<a href="/batches/">Batches</a>
|
||||||
|
<a href="/tasks/">Tasks</a>
|
||||||
|
<a href="/manual/">Manual</a>
|
||||||
|
<a href="/readme/">README</a>
|
||||||
|
<span class="ml-auto">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="inline-flex items-center gap-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<span class="text-sm opacity-80">{{ user.username }}</span>
|
||||||
|
<button type="submit" class="underline hover:text-red-300">Logout</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'login' %}" class="underline">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<main class="p-4 max-w-6xl mx-auto">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
12
project/remotectl/templates/remotectl/batch_delete.html
Normal file
12
project/remotectl/templates/remotectl/batch_delete.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Delete Batch | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Delete Batch Script</h1>
|
||||||
|
<p class="mb-4">Are you sure you want to delete <span class="font-semibold">{{ script.name }}</span>?</p>
|
||||||
|
<form method="post" class="space-x-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded shadow text-sm">Delete</button>
|
||||||
|
<a href="/batches/"
|
||||||
|
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
28
project/remotectl/templates/remotectl/batch_form.html
Normal file
28
project/remotectl/templates/remotectl/batch_form.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% load remotectl_extras %}
|
||||||
|
{% block title %}Batch Script | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Batch Script</h1>
|
||||||
|
<form method="post"
|
||||||
|
class="space-y-5 max-w-5xl bg-white dark:bg-gray-800 p-6 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide text-gray-600 dark:text-gray-400"
|
||||||
|
for="id_{{ field.name }}">{{ field.label }}</label>
|
||||||
|
{{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900 font-mono text-sm' }}
|
||||||
|
{% if field.name == 'script' %}
|
||||||
|
<p class="text-xs text-gray-500 mt-1">One command per line. Lines starting with # are ignored. Use 'cd path' to
|
||||||
|
set a working directory for subsequent steps.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% for e in field.errors %}<p class="text-xs text-red-600 mt-1">{{ e }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded shadow text-sm">Save</button>
|
||||||
|
<a href="/batches/"
|
||||||
|
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
# Batch Script Help
|
||||||
|
# One command per line. Lines beginning with # are ignored.
|
||||||
|
# Example:
|
||||||
|
# cd /opt/app
|
||||||
|
# git pull origin master
|
||||||
|
# systemctl restart app@instance
|
||||||
38
project/remotectl/templates/remotectl/batch_list.html
Normal file
38
project/remotectl/templates/remotectl/batch_list.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Batches | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">Batch Scripts</h1>
|
||||||
|
<a href="/batches/new/" class="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow">+ New</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">Name</th>
|
||||||
|
<th class="px-3 py-2 text-left">Description</th>
|
||||||
|
<th class="px-3 py-2 text-left">Updated</th>
|
||||||
|
<th class="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for s in scripts %}
|
||||||
|
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
|
||||||
|
<td class="px-3 py-2 font-medium">{{ s.name }}</td>
|
||||||
|
<td class="px-3 py-2 truncate max-w-md">{{ s.description }}</td>
|
||||||
|
<td class="px-3 py-2 text-xs">{{ s.updated_at|date:'Y-m-d H:i' }}</td>
|
||||||
|
<td class="px-3 py-2 text-right space-x-2">
|
||||||
|
<a href="/batches/{{ s.id }}/edit/" class="text-blue-600 hover:underline">Edit</a>
|
||||||
|
<a href="/batches/{{ s.id }}/delete/" hx-confirm="Delete batch '{{ s.name }}'?"
|
||||||
|
class="text-red-600 hover:underline">Del</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-3 py-6 text-center text-gray-500">No batch scripts.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
132
project/remotectl/templates/remotectl/console.html
Normal file
132
project/remotectl/templates/remotectl/console.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Console | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6 flex items-center gap-3"><span>Console</span><span id="statusBadge"
|
||||||
|
class="text-xs px-2 py-1 rounded bg-gray-300 dark:bg-gray-700 text-gray-800 dark:text-gray-200">Disconnected</span>
|
||||||
|
</h1>
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div class="md:col-span-1 space-y-6">
|
||||||
|
<section class="p-4 rounded-lg shadow bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="font-semibold mb-3 text-sm uppercase tracking-wide text-gray-600 dark:text-gray-400">Connection
|
||||||
|
</h2>
|
||||||
|
<label class="block text-xs font-medium mb-1">Host</label>
|
||||||
|
<select id="hostSelect" class="w-full mb-3 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900">
|
||||||
|
<option value="">-- choose --</option>
|
||||||
|
{% for h in hosts %}<option value="{{ h.id }}">{{ h.name }} ({{ h.username }}@{{ h.hostname }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="connectBtn"
|
||||||
|
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white text-sm py-1.5 rounded">Connect</button>
|
||||||
|
<button id="disconnectBtn" disabled
|
||||||
|
class="flex-1 bg-red-600/60 hover:bg-red-600 text-white text-sm py-1.5 rounded">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="p-4 rounded-lg shadow bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<h2 class="font-semibold mb-1 text-sm uppercase tracking-wide text-gray-600 dark:text-gray-400">Task /
|
||||||
|
Command</h2>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">Saved Task</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select id="taskSelect"
|
||||||
|
class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900">
|
||||||
|
<option value="">-- none --</option>
|
||||||
|
{% for task in tasks %}<option value="{{ task.name }}">{{ task.label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<a href="/tasks/"
|
||||||
|
class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Manage</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">Ad-hoc Command</label>
|
||||||
|
<input id="commandInput" type="text"
|
||||||
|
class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" placeholder="uptime" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button id="runBtn" disabled
|
||||||
|
class="flex-1 bg-green-600/70 hover:bg-green-600 text-white text-sm py-1.5 rounded">Run</button>
|
||||||
|
<button id="cancelBtn" disabled
|
||||||
|
class="flex-1 bg-yellow-500/60 hover:bg-yellow-500 text-white text-sm py-1.5 rounded">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1 mt-2">Batch Script</label>
|
||||||
|
<select id="batchSelect" class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900">
|
||||||
|
<option value="">-- none --</option>
|
||||||
|
</select>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button id="runBatchBtn" disabled
|
||||||
|
class="flex-1 bg-purple-600/70 hover:bg-purple-600 text-white text-sm py-1.5 rounded">Run
|
||||||
|
Batch</button>
|
||||||
|
<a href="/batches/"
|
||||||
|
class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Manage</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="p-4 rounded-lg shadow bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-xs leading-relaxed">
|
||||||
|
<h2 class="font-semibold mb-2 text-sm uppercase tracking-wide text-gray-600 dark:text-gray-400">Status</h2>
|
||||||
|
<div id="statusBar" class="font-mono break-words">Disconnected</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2 flex flex-col">
|
||||||
|
<div class="flex items-center justify-between mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Live Output</span>
|
||||||
|
<button id="clearBtn"
|
||||||
|
class="px-2 py-0.5 border rounded border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<pre id="terminal"
|
||||||
|
class="bg-black text-green-300 rounded-lg p-3 font-mono text-sm h-[560px] overflow-y-auto whitespace-pre-wrap"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const sessionId = "{{ session_id }}";
|
||||||
|
let socket = null; let running = false; let canceling = false; let batchTotal = 0; let currentStep = 0; let lastExit = null;
|
||||||
|
const termEl = document.getElementById('terminal');
|
||||||
|
function badge(status, extra='') {
|
||||||
|
const b = document.getElementById('statusBadge');
|
||||||
|
const map = { disconnected:'bg-gray-400 text-gray-900', connected:'bg-blue-500 text-white', running:'bg-amber-500 text-white', completed_ok:'bg-green-600 text-white', completed_failed:'bg-red-600 text-white', completed_error:'bg-red-700 text-white', canceled:'bg-yellow-600 text-white', canceling:'bg-yellow-500 text-white', error:'bg-red-600 text-white'};
|
||||||
|
b.textContent = status.replace('_',' ').toUpperCase() + (extra?(' '+extra):'') + (lastExit!==null?` (exit ${lastExit})`:'' );
|
||||||
|
b.className = 'text-xs px-2 py-1 rounded ' + (map[status] || 'bg-gray-500 text-white');
|
||||||
|
document.getElementById('statusBar').textContent = b.textContent;
|
||||||
|
}
|
||||||
|
function append(stream, text) { const span = document.createElement('span'); if (stream === 'stderr') span.className = 'text-red-400'; span.textContent = text; termEl.appendChild(span); termEl.scrollTop = termEl.scrollHeight; }
|
||||||
|
function setButtons(state) {
|
||||||
|
const disabledRunning = running || canceling;
|
||||||
|
document.getElementById('connectBtn').disabled = state !== 'disconnected';
|
||||||
|
document.getElementById('disconnectBtn').disabled = state === 'disconnected';
|
||||||
|
document.getElementById('runBtn').disabled = !(state === 'connected' && !disabledRunning);
|
||||||
|
document.getElementById('cancelBtn').disabled = !running || canceling;
|
||||||
|
document.getElementById('runBatchBtn').disabled = !(state === 'connected' && !disabledRunning && document.getElementById('batchSelect').value);
|
||||||
|
}
|
||||||
|
setButtons('disconnected'); badge('disconnected');
|
||||||
|
document.getElementById('clearBtn').onclick = () => { termEl.textContent = ''; };
|
||||||
|
function clearTaskBatchIf(valId, others) { if (document.getElementById(valId).value) { others.forEach(id => document.getElementById(id).value = ''); } }
|
||||||
|
document.getElementById('taskSelect').addEventListener('change', () => { clearTaskBatchIf('taskSelect', ['commandInput','batchSelect']); setButtons('connected'); });
|
||||||
|
document.getElementById('commandInput').addEventListener('input', () => { clearTaskBatchIf('commandInput', ['taskSelect','batchSelect']); setButtons('connected'); });
|
||||||
|
document.getElementById('batchSelect').addEventListener('change', () => { if (document.getElementById('batchSelect').value) { document.getElementById('taskSelect').value=''; document.getElementById('commandInput').value=''; } setButtons('connected'); });
|
||||||
|
document.getElementById('connectBtn').onclick = () => {
|
||||||
|
const hostId = document.getElementById('hostSelect').value; if (!hostId) { alert('Select a host'); return; }
|
||||||
|
socket = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host + `/ws/ssh/${sessionId}/stream/`);
|
||||||
|
socket.onopen = () => {
|
||||||
|
badge('connected'); setButtons('connected'); Promise.all([
|
||||||
|
fetch('/batches/json/', { headers: { 'Accept': 'application/json' } }).then(r => r.ok ? r.json() : Promise.reject('Bad response')),
|
||||||
|
fetch('/tasks/json/', { headers: { 'Accept': 'application/json' } }).then(r => r.ok ? r.json() : Promise.reject('Bad response'))
|
||||||
|
]).then(([batches, tasks]) => { const batchSel = document.getElementById('batchSelect'); batchSel.innerHTML = '<option value="">-- none --</option>'; batches.forEach(b => { const o = document.createElement('option'); o.value = b.id; o.textContent = `${b.name}`; batchSel.appendChild(o); }); const taskSel = document.getElementById('taskSelect'); taskSel.querySelectorAll('option').forEach(o => { if (o.value) o.remove(); }); tasks.forEach(t => { const o = document.createElement('option'); o.value = t.name; o.textContent = t.label; taskSel.appendChild(o); }); }).catch(err => console.error('Data load failed', err));
|
||||||
|
};
|
||||||
|
socket.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.event === 'chunk') { append(msg.stream, msg.data); }
|
||||||
|
else if (msg.event === 'progress') { currentStep = msg.current; batchTotal = msg.total; badge('running', `STEP ${currentStep}/${batchTotal}`); }
|
||||||
|
else if (msg.event === 'started') { running = true; canceling = false; lastExit = null; badge('running'); setButtons('connected'); }
|
||||||
|
else if (msg.event === 'canceling') { canceling = true; badge('canceling'); setButtons('connected'); }
|
||||||
|
else if (msg.event === 'completed') { running = false; canceling = false; lastExit = msg.exit_code; let st = msg.status; if (st==='ok') badge('completed_ok'); else if (st==='failed') badge('completed_failed'); else if (st==='error') badge('completed_error'); else if (st==='canceled') badge('canceled'); setButtons('connected'); }
|
||||||
|
else if (msg.event === 'error') { append('stderr', `ERROR: ${msg.message}\n`); running = false; canceling = false; badge('error'); setButtons('connected'); } };
|
||||||
|
socket.onclose = () => { badge('disconnected'); setButtons('disconnected'); running = false; canceling = false; };
|
||||||
|
};
|
||||||
|
document.getElementById('runBtn').onclick = () => { if (!socket || socket.readyState !== 1) { alert('Not connected'); return; } const hostId = document.getElementById('hostSelect').value; const taskKey = document.getElementById('taskSelect').value || null; const command = document.getElementById('commandInput').value.trim() || null; if (!taskKey && !command) { alert('Enter a command or choose a task'); return; } termEl.textContent = ''; socket.send(JSON.stringify({ action: 'start', host_id: hostId, task_key: taskKey, command: command })); };
|
||||||
|
document.getElementById('runBatchBtn').onclick = () => { if (!socket || socket.readyState !== 1) { alert('Not connected'); return; } const hostId = document.getElementById('hostSelect').value; const batchId = document.getElementById('batchSelect').value; if (!batchId) { alert('Select batch'); return; } termEl.textContent=''; currentStep=0; batchTotal=0; socket.send(JSON.stringify({ action: 'start_batch', host_id: hostId, batch_id: batchId })); };
|
||||||
|
document.getElementById('cancelBtn').onclick = () => { if (socket) socket.send(JSON.stringify({ action: 'cancel' })); };
|
||||||
|
document.getElementById('disconnectBtn').onclick = () => { if (socket) { socket.send(JSON.stringify({ action: 'disconnect' })); socket.close(); } };
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
29
project/remotectl/templates/remotectl/host_form.html
Normal file
29
project/remotectl/templates/remotectl/host_form.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% load remotectl_extras %}
|
||||||
|
{% block title %}Host | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Host</h1>
|
||||||
|
<form method="post"
|
||||||
|
class="space-y-5 max-w-2xl bg-white dark:bg-gray-800 p-6 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide text-gray-600 dark:text-gray-400"
|
||||||
|
for="id_{{ field.name }}">{{ field.label }}</label>
|
||||||
|
{% if field.field.widget.input_type == 'checkbox' %}
|
||||||
|
<div class="flex items-center gap-2">{{ field }} {% if field.help_text %}<span class="text-xs text-gray-500">{{
|
||||||
|
field.help_text }}</span>{% endif %}</div>
|
||||||
|
{% else %}
|
||||||
|
{{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900' }}
|
||||||
|
{% endif %}
|
||||||
|
{% for e in field.errors %}<p class="text-xs text-red-600 mt-1">{{ e }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded shadow text-sm">Save</button>
|
||||||
|
<a href="/hosts/"
|
||||||
|
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
40
project/remotectl/templates/remotectl/host_list.html
Normal file
40
project/remotectl/templates/remotectl/host_list.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Hosts | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">Hosts</h1>
|
||||||
|
<a href="/hosts/new/"
|
||||||
|
class="inline-flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow">+
|
||||||
|
New Host</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">Name</th>
|
||||||
|
<th class="px-3 py-2 text-left">Host</th>
|
||||||
|
<th class="px-3 py-2 text-left">User</th>
|
||||||
|
<th class="px-3 py-2 text-left">Auth</th>
|
||||||
|
<th class="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for h in hosts %}
|
||||||
|
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
|
||||||
|
<td class="px-3 py-2 font-medium">{{ h.name }}</td>
|
||||||
|
<td class="px-3 py-2">{{ h.hostname }}:<span class="text-xs text-gray-500">{{ h.port }}</span></td>
|
||||||
|
<td class="px-3 py-2">{{ h.username }}</td>
|
||||||
|
<td class="px-3 py-2"><span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-xs">{{
|
||||||
|
h.get_auth_method_display }}</span></td>
|
||||||
|
<td class="px-3 py-2 text-right"><a class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
href="/hosts/{{ h.id }}/edit/">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-3 py-6 text-center text-gray-500">No hosts yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
project/remotectl/templates/remotectl/log_detail.html
Normal file
32
project/remotectl/templates/remotectl/log_detail.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Log {{ log.id }} | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Log #{{ log.id }}</h1>
|
||||||
|
<div class="grid md:grid-cols-3 gap-6">
|
||||||
|
<div class="space-y-4 md:col-span-1">
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm space-y-1">
|
||||||
|
<div><span class="font-semibold">Host:</span> {{ log.host }}</div>
|
||||||
|
<div><span class="font-semibold">Type:</span> {{ log.run_type }}</div>
|
||||||
|
<div><span class="font-semibold">Status:</span> {{ log.status }}{% if log.failed_step %} (failed step
|
||||||
|
{{ log.failed_step }}){% endif %}</div>
|
||||||
|
<div><span class="font-semibold">Exit Code:</span> {{ log.exit_code }}</div>
|
||||||
|
<div><span class="font-semibold">Started:</span> {{ log.started_at }}</div>
|
||||||
|
<div><span class="font-semibold">Finished:</span> {{ log.finished_at }}</div>
|
||||||
|
<div><span class="font-semibold">Duration (s):</span> {% if log.finished_at %}{{ log.duration|floatformat:2
|
||||||
|
}}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm">
|
||||||
|
<div class="font-semibold mb-2">Command</div>
|
||||||
|
<code class="block text-xs break-words">{{ log.command }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="p-4 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="font-semibold mb-2">Output Tail</h2>
|
||||||
|
<pre
|
||||||
|
class="bg-black text-green-300 p-3 rounded-lg max-h-[560px] overflow-y-auto text-sm whitespace-pre-wrap">{{ log.output_tail }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
49
project/remotectl/templates/remotectl/log_list.html
Normal file
49
project/remotectl/templates/remotectl/log_list.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Logs | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Logs</h1>
|
||||||
|
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">Started</th>
|
||||||
|
<th class="px-3 py-2 text-left">Host</th>
|
||||||
|
<th class="px-3 py-2 text-left">Type</th>
|
||||||
|
<th class="px-3 py-2 text-left">Command</th>
|
||||||
|
<th class="px-3 py-2 text-left">Status</th>
|
||||||
|
<th class="px-3 py-2 text-left">Exit</th>
|
||||||
|
<th class="px-3 py-2 text-left">Dur (s)</th>
|
||||||
|
<th class="px-3 py-2 text-left">User</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
|
||||||
|
<td class="px-3 py-2"><a class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
href="/logs/{{ log.id }}/">{{ log.started_at|date:'Y-m-d H:i:s' }}</a></td>
|
||||||
|
<td class="px-3 py-2">{{ log.host.name }}</td>
|
||||||
|
<td class="px-3 py-2">{{ log.run_type }}</td>
|
||||||
|
<td class="px-3 py-2 truncate max-w-xs">{{ log.command|truncatechars:60 }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% with s=log.status %}
|
||||||
|
{% if s == 'ok' %}<span class="px-2 py-0.5 rounded bg-green-600 text-white text-xs">OK</span>
|
||||||
|
{% elif s == 'failed' %}<span class="px-2 py-0.5 rounded bg-red-600 text-white text-xs">FAILED{% if log.failed_step %}#{{ log.failed_step }}{% endif %}</span>
|
||||||
|
{% elif s == 'canceled' %}<span
|
||||||
|
class="px-2 py-0.5 rounded bg-yellow-500 text-white text-xs">CANCELED</span>
|
||||||
|
{% elif s == 'error' %}<span class="px-2 py-0.5 rounded bg-pink-600 text-white text-xs">ERROR{% if log.failed_step %}#{{ log.failed_step }}{% endif %}</span>
|
||||||
|
{% else %}<span class="px-2 py-0.5 rounded bg-blue-600 text-white text-xs">RUNNING</span>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">{{ log.exit_code }}</td>
|
||||||
|
<td class="px-3 py-2">{% if log.finished_at %}{{ log.duration|floatformat:2 }}{% endif %}</td>
|
||||||
|
<td class="px-3 py-2">{{ log.created_by }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="px-3 py-6 text-center text-gray-500">No logs.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
130
project/remotectl/templates/remotectl/manual.html
Normal file
130
project/remotectl/templates/remotectl/manual.html
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Manual | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-3xl font-semibold mb-6">Application Manual</h1>
|
||||||
|
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">Detailed reference for using the Remote Admin console. Use your
|
||||||
|
browser search (Ctrl+F) to find topics quickly.</p>
|
||||||
|
<div class="prose dark:prose-invert max-w-none text-sm">
|
||||||
|
<h2 id="overview">1. Overview</h2>
|
||||||
|
<p>This application lets you manage remote Linux hosts over SSH, execute ad-hoc commands, run saved tasks, and
|
||||||
|
process multi-step batch scripts with real-time streaming output via WebSockets.</p>
|
||||||
|
|
||||||
|
<h2 id="hosts">2. Hosts</h2>
|
||||||
|
<p>Create remote hosts under <strong>Hosts</strong>. Each host stores:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>name</code>: Friendly display name</li>
|
||||||
|
<li><code>hostname</code>: DNS name or IP</li>
|
||||||
|
<li><code>port</code>: SSH port (default 22)</li>
|
||||||
|
<li><code>username</code>: SSH login user</li>
|
||||||
|
<li><code>auth_method</code>: <code>ssh_key</code> or <code>agent</code> (password not yet implemented)</li>
|
||||||
|
<li><code>key_path</code>: Optional explicit private key path (not uploaded)</li>
|
||||||
|
<li><code>strict_host_key_checking</code>: Enforce known_hosts validation</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Example:</strong></p>
|
||||||
|
<pre>Host: prod-app-1
|
||||||
|
Hostname: 10.10.10.15
|
||||||
|
User: deploy
|
||||||
|
Auth: ssh_key
|
||||||
|
Key Path: /home/deploy/.ssh/id_ed25519</pre>
|
||||||
|
|
||||||
|
<h2 id="console">3. Console</h2>
|
||||||
|
<p>Connect to a host then choose one of:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Saved Task</strong>: Select a predefined, editable command.</li>
|
||||||
|
<li><strong>Ad-hoc Command</strong>: Type any shell command (e.g. <code>uname -a</code>).</li>
|
||||||
|
<li><strong>Batch Script</strong>: Multi-step script defined under Batches.</li>
|
||||||
|
</ul>
|
||||||
|
<p>While running you can cancel. Output streams in real-time; stderr lines are highlighted.</p>
|
||||||
|
|
||||||
|
<h2 id="tasks">4. Tasks</h2>
|
||||||
|
<p>Tasks are named reusable commands. Fields:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>name</code>: Unique internal key (no spaces recommended)</li>
|
||||||
|
<li><code>label</code>: Display label</li>
|
||||||
|
<li><code>command</code>: Shell snippet executed exactly on remote host</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Example task:</strong></p>
|
||||||
|
<pre>Name: restart_app
|
||||||
|
Label: Restart App Service
|
||||||
|
Command: sudo systemctl restart app.service</pre>
|
||||||
|
|
||||||
|
<h2 id="batches">5. Batch Scripts</h2>
|
||||||
|
<p>A batch script is a list of steps (one per line). Blank lines and lines starting with <code>#</code> are ignored.
|
||||||
|
A line starting with <code>cd <dir></code> sets the working directory for subsequent commands.</p>
|
||||||
|
<p><strong>Example batch:</strong></p>
|
||||||
|
<pre># Deploy sequence
|
||||||
|
cd /srv/app
|
||||||
|
./stop.sh
|
||||||
|
./build.sh
|
||||||
|
./start.sh</pre>
|
||||||
|
<p>During execution you will see markers like:</p>
|
||||||
|
<pre>>>> [2/4] ./build.sh</pre>
|
||||||
|
|
||||||
|
<h2 id="status">6. Status & Progress</h2>
|
||||||
|
<p>Status badge meanings:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>CONNECTED</strong>: WebSocket open, no command running</li>
|
||||||
|
<li><strong>RUNNING</strong>: Command or batch executing</li>
|
||||||
|
<li><strong>STEP X/Y</strong>: Current batch step progress</li>
|
||||||
|
<li><strong>CANCELING</strong>: Cancel requested; waiting for termination</li>
|
||||||
|
<li><strong>COMPLETED (exit n)</strong>: Finished with exit code</li>
|
||||||
|
<li><strong>FAILED / ERROR</strong>: Non-zero exit or SSH/runtime issue</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="logs">7. Logs</h2>
|
||||||
|
<p>Each execution creates a log entry with:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>run_type</code>: <code>single</code> or <code>batch</code></li>
|
||||||
|
<li><code>status</code>, <code>exit_code</code></li>
|
||||||
|
<li><code>failed_step</code> (batch only on failure)</li>
|
||||||
|
<li>Tail output (last 32K captured)</li>
|
||||||
|
<li>Timestamps and duration</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="errors">8. Errors</h2>
|
||||||
|
<p>Error events are structured as:</p>
|
||||||
|
<pre>{"event":"error","type":"ssh|runtime","message":"..."}</pre>
|
||||||
|
<p>SSH errors come from connection/auth issues; runtime errors are validation or state (e.g. duplicate run).</p>
|
||||||
|
|
||||||
|
<h2 id="cancellation">9. Cancellation</h2>
|
||||||
|
<p>When you press Cancel the server sends a terminate signal; some commands may take time to exit gracefully. Batch
|
||||||
|
processing stops at the current step.</p>
|
||||||
|
|
||||||
|
<h2 id="security">10. Security Notes</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Private keys are referenced by filesystem path only (never stored in DB).</li>
|
||||||
|
<li>Strict host key checking recommended for production.</li>
|
||||||
|
<li>All operations require authentication (Django login).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="examples">11. Example Use Cases</h2>
|
||||||
|
<h3>Deploy Application</h3>
|
||||||
|
<pre>Task: build_assets
|
||||||
|
Command: npm run build
|
||||||
|
|
||||||
|
Batch:
|
||||||
|
cd /srv/app
|
||||||
|
./stop.sh
|
||||||
|
./deploy.sh
|
||||||
|
./start.sh</pre>
|
||||||
|
|
||||||
|
<h3>Quick Diagnostics</h3>
|
||||||
|
<pre>Ad-hoc: df -h | grep /data
|
||||||
|
Ad-hoc: sudo journalctl -u app.service -n 50</pre>
|
||||||
|
|
||||||
|
<h2 id="troubleshooting">12. Troubleshooting</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Permission denied</strong>: Check user, key, and file permissions.</li>
|
||||||
|
<li><strong>Host key not trusted</strong>: Add host to known_hosts or disable strict checking (dev only).</li>
|
||||||
|
<li><strong>No output</strong>: Some scripts buffer output; use unbuffered modes (e.g. <code>python -u</code>).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="future">13. Future Extensions</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Parameterized tasks</li>
|
||||||
|
<li>Role-based permissions</li>
|
||||||
|
<li>Streaming partial log persistence</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
6
project/remotectl/templates/remotectl/readme.html
Normal file
6
project/remotectl/templates/remotectl/readme.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}README | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-3xl font-semibold mb-6">Project README</h1>
|
||||||
|
<div class="prose dark:prose-invert max-w-none text-sm">{{ readme_html|safe }}</div>
|
||||||
|
{% endblock %}
|
||||||
12
project/remotectl/templates/remotectl/task_delete.html
Normal file
12
project/remotectl/templates/remotectl/task_delete.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Delete Task | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Delete Task</h1>
|
||||||
|
<p class="mb-4">Are you sure you want to delete <span class="font-semibold">{{ task.label }}</span>?</p>
|
||||||
|
<form method="post" class="space-x-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded shadow text-sm">Delete</button>
|
||||||
|
<a href="/tasks/"
|
||||||
|
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
26
project/remotectl/templates/remotectl/task_form.html
Normal file
26
project/remotectl/templates/remotectl/task_form.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% load remotectl_extras %}
|
||||||
|
{% block title %}Task | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Task</h1>
|
||||||
|
<form method="post"
|
||||||
|
class="space-y-5 max-w-3xl bg-white dark:bg-gray-800 p-6 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide text-gray-600 dark:text-gray-400"
|
||||||
|
for="id_{{ field.name }}">{{ field.label }}</label>
|
||||||
|
{{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900 font-mono text-sm' }}
|
||||||
|
{% if field.name == 'command' %}<p class="text-xs text-gray-500 mt-1">Shell snippet executed exactly as entered
|
||||||
|
on the remote host.</p>{% endif %}
|
||||||
|
{% for e in field.errors %}<p class="text-xs text-red-600 mt-1">{{ e }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded shadow text-sm">Save</button>
|
||||||
|
<a href="/tasks/"
|
||||||
|
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
34
project/remotectl/templates/remotectl/task_list.html
Normal file
34
project/remotectl/templates/remotectl/task_list.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{% extends 'remotectl/base.html' %}
|
||||||
|
{% block title %}Tasks | Remote Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">Tasks</h1>
|
||||||
|
<a href="/tasks/new/" class="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow">+ New</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">Label</th>
|
||||||
|
<th class="px-3 py-2 text-left">Command</th>
|
||||||
|
<th class="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for t in tasks_db %}
|
||||||
|
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
|
||||||
|
<td class="px-3 py-2 font-medium">{{ t.label }}</td>
|
||||||
|
<td class="px-3 py-2 text-xs font-mono whitespace-pre">{{ t.command|truncatechars:160 }}</td>
|
||||||
|
<td class="px-3 py-2 text-right space-x-2"><a href="/tasks/{{ t.id }}/edit/"
|
||||||
|
class="text-blue-600 hover:underline">Edit</a><a href="/tasks/{{ t.id }}/delete/"
|
||||||
|
hx-confirm="Delete task '{{ t.label }}'?" class="text-red-600 hover:underline">Del</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-3 py-6 text-center text-gray-500">No tasks.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
0
project/remotectl/templatetags/__init__.py
Normal file
0
project/remotectl/templatetags/__init__.py
Normal file
10
project/remotectl/templatetags/remotectl_extras.py
Normal file
10
project/remotectl/templatetags/remotectl_extras.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
@register.filter(name="add_class")
|
||||||
|
def add_class(field, css):
|
||||||
|
# Preserve existing classes and append new ones
|
||||||
|
existing = (field.field.widget.attrs or {}).get('class', '')
|
||||||
|
combined = (existing + ' ' + css).strip()
|
||||||
|
return field.as_widget(attrs={**(field.field.widget.attrs or {}), 'class': combined})
|
||||||
0
project/remotectl/tests/__init__.py
Normal file
0
project/remotectl/tests/__init__.py
Normal file
11
project/remotectl/tests/conftest.py
Normal file
11
project/remotectl/tests/conftest.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_factory():
|
||||||
|
User = get_user_model()
|
||||||
|
def make_user(**kwargs):
|
||||||
|
defaults = dict(username='user')
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return User.objects.create(**defaults)
|
||||||
|
return make_user
|
||||||
20
project/remotectl/tests/test_batch.py
Normal file
20
project/remotectl/tests/test_batch.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import pytest
|
||||||
|
from channels.testing import WebsocketCommunicator
|
||||||
|
from django.urls import reverse
|
||||||
|
from project.config.asgi import application
|
||||||
|
from project.remotectl.models import RemoteHost, BatchScript
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_model_creation(django_user_model):
|
||||||
|
user = await django_user_model.objects.acreate(username='u')
|
||||||
|
b = await BatchScript.objects.acreate(name='test', description='d', script='echo hi')
|
||||||
|
assert b.name == 'test'
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_ws_requires_auth():
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/ssh/11111111-1111-1111-1111-111111111111/stream/")
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
assert connected is False or (await communicator.receive_nothing(timeout=0.1) is None)
|
||||||
|
await communicator.disconnect()
|
||||||
81
project/remotectl/tests/test_crud.py
Normal file
81
project/remotectl/tests/test_crud.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from project.remotectl.models import RemoteHost, BatchScript, CommandTask
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_host_crud(client, user_factory):
|
||||||
|
user = user_factory(username='u1')
|
||||||
|
client.force_login(user)
|
||||||
|
# create
|
||||||
|
resp = client.post(reverse('host_create'), {
|
||||||
|
'name': 'TestHost', 'hostname': '127.0.0.1', 'port': 22, 'username': 'root',
|
||||||
|
'auth_method': 'ssh_key', 'key_path': '', 'strict_host_key_checking': True, 'notes': 'n'
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert RemoteHost.objects.filter(name='TestHost').exists()
|
||||||
|
host = RemoteHost.objects.get(name='TestHost')
|
||||||
|
# edit
|
||||||
|
resp = client.post(f"/hosts/{host.id}/edit/", {
|
||||||
|
'name': 'TestHost2', 'hostname': '127.0.0.1', 'port': 22, 'username': 'root',
|
||||||
|
'auth_method': 'ssh_key', 'key_path': '', 'strict_host_key_checking': True, 'notes': 'n2'
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
host.refresh_from_db()
|
||||||
|
assert host.name == 'TestHost2'
|
||||||
|
# list
|
||||||
|
resp = client.get(reverse('host_list'))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'TestHost2' in resp.content.decode()
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_batch_crud(client, user_factory):
|
||||||
|
user = user_factory(username='u2')
|
||||||
|
client.force_login(user)
|
||||||
|
# create
|
||||||
|
resp = client.post(reverse('batch_create'), {
|
||||||
|
'name': 'batch1', 'description': 'desc', 'script': 'echo one'
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
b = BatchScript.objects.get(name='batch1')
|
||||||
|
# edit
|
||||||
|
resp = client.post(f"/batches/{b.id}/edit/", {
|
||||||
|
'name': 'batch1', 'description': 'desc2', 'script': 'echo two'
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
b.refresh_from_db()
|
||||||
|
assert 'two' in b.script
|
||||||
|
# list json
|
||||||
|
resp = client.get(reverse('batch_list_json'))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'batch1' in resp.json()[0]['name']
|
||||||
|
# delete
|
||||||
|
resp = client.post(f"/batches/{b.id}/delete/")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert not BatchScript.objects.filter(id=b.id).exists()
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_task_crud_and_json(client, user_factory):
|
||||||
|
user = user_factory(username='u3')
|
||||||
|
client.force_login(user)
|
||||||
|
# create
|
||||||
|
resp = client.post(reverse('task_create'), {
|
||||||
|
'name': 'task1', 'label': 'Task One', 'command': 'uptime', 'description': 'd'
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
t = CommandTask.objects.get(name='task1')
|
||||||
|
# edit
|
||||||
|
resp = client.post(f"/tasks/{t.id}/edit/", {
|
||||||
|
'name': 'task1', 'label': 'Task 1', 'command': 'echo hi', 'description': 'd2'
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
t.refresh_from_db()
|
||||||
|
assert 'echo hi' in t.command
|
||||||
|
# json list
|
||||||
|
resp = client.get(reverse('task_list_json'))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert any(item['name'] == 'task1' for item in data)
|
||||||
|
# delete
|
||||||
|
resp = client.post(f"/tasks/{t.id}/delete/")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert not CommandTask.objects.filter(id=t.id).exists()
|
||||||
14
project/remotectl/tests/test_dummy_consumer.py
Normal file
14
project/remotectl/tests/test_dummy_consumer.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import pytest
|
||||||
|
from channels.testing import WebsocketCommunicator
|
||||||
|
from project.config.asgi import application
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
async def test_requires_auth():
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
communicator = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/")
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
assert connected is False or (await communicator.receive_nothing(timeout=0.1) is None)
|
||||||
|
await communicator.disconnect()
|
||||||
16
project/remotectl/tests/test_models.py
Normal file
16
project/remotectl/tests/test_models.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
from project.remotectl.models import RemoteHost, CommandLog
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_host():
|
||||||
|
h = RemoteHost.objects.create(name='Local', hostname='127.0.0.1', username='root')
|
||||||
|
assert str(h).startswith('Local')
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_command_log_mark_finished(user_factory, django_user_model):
|
||||||
|
user = django_user_model.objects.create(username='tester')
|
||||||
|
h = RemoteHost.objects.create(name='Local', hostname='127.0.0.1', username='root')
|
||||||
|
log = CommandLog.objects.create(host=h, command='echo hi', created_by=user)
|
||||||
|
log.mark_finished('ok', 0, 'hi')
|
||||||
|
assert log.status == 'ok'
|
||||||
|
assert log.exit_code == 0
|
||||||
77
project/remotectl/tests/test_ws_execution.py
Normal file
77
project/remotectl/tests/test_ws_execution.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import pytest
|
||||||
|
import uuid
|
||||||
|
from channels.testing import WebsocketCommunicator
|
||||||
|
from project.config.asgi import application
|
||||||
|
from project.remotectl.models import RemoteHost
|
||||||
|
|
||||||
|
class DummyProc:
|
||||||
|
def __init__(self, chunks, exit_status=0):
|
||||||
|
self._chunks = chunks
|
||||||
|
self._exit_status_value = exit_status
|
||||||
|
self.stdout_iter = self._iter_stream('stdout')
|
||||||
|
self.stderr_iter = self._iter_stream('stderr')
|
||||||
|
self.stdout = self._aiter(self.stdout_iter)
|
||||||
|
self.stderr = self._aiter(self.stderr_iter)
|
||||||
|
self._done = False
|
||||||
|
|
||||||
|
def _iter_stream(self, which):
|
||||||
|
for stream, data in self._chunks:
|
||||||
|
if stream == which:
|
||||||
|
yield data
|
||||||
|
|
||||||
|
async def _aiter(self, it):
|
||||||
|
for item in it:
|
||||||
|
yield item
|
||||||
|
self._done = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_status(self):
|
||||||
|
return self._exit_status_value if self._done else None
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self._done = True
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self._done = True
|
||||||
|
|
||||||
|
class DummyConn:
|
||||||
|
def __init__(self, procs):
|
||||||
|
self.procs = procs
|
||||||
|
async def create_process(self, command):
|
||||||
|
return self.procs.pop(0)
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_simple_command(monkeypatch, django_user_model):
|
||||||
|
user = await django_user_model.objects.acreate(username='user')
|
||||||
|
host = await RemoteHost.objects.acreate(name='h', hostname='localhost', username='u')
|
||||||
|
|
||||||
|
from project.remotectl import consumers
|
||||||
|
async def fake_open_connection(rh):
|
||||||
|
return DummyConn([DummyProc([('stdout', 'line1\n'), ('stdout', 'line2\n')], exit_status=0)])
|
||||||
|
monkeypatch.setattr(consumers, 'open_connection', fake_open_connection)
|
||||||
|
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
communicator = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/")
|
||||||
|
communicator.scope['user'] = user
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
assert connected
|
||||||
|
msg = await communicator.receive_json_from()
|
||||||
|
assert msg['event'] == 'connected'
|
||||||
|
|
||||||
|
await communicator.send_json_to({'action':'start','host_id': host.id, 'command':'echo hi'})
|
||||||
|
started_or_error = await communicator.receive_json_from()
|
||||||
|
assert started_or_error['event'] == 'started'
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
msg = await communicator.receive_json_from()
|
||||||
|
if msg['event'] == 'chunk':
|
||||||
|
chunks.append(msg['data'].strip())
|
||||||
|
elif msg['event'] == 'completed':
|
||||||
|
assert msg['status'] == 'ok'
|
||||||
|
break
|
||||||
|
assert 'line1' in chunks and 'line2' in chunks
|
||||||
|
await communicator.disconnect()
|
||||||
167
project/remotectl/tests/test_ws_execution_more.py
Normal file
167
project/remotectl/tests/test_ws_execution_more.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import pytest
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
from channels.testing import WebsocketCommunicator
|
||||||
|
from project.config.asgi import application
|
||||||
|
from project.remotectl.models import RemoteHost, BatchScript, CommandTask
|
||||||
|
|
||||||
|
class DummyConn:
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_failure(monkeypatch, django_user_model):
|
||||||
|
user = await django_user_model.objects.acreate(username='userf')
|
||||||
|
host = await RemoteHost.objects.acreate(name='hf', hostname='localhost', username='u')
|
||||||
|
|
||||||
|
from project.remotectl import consumers
|
||||||
|
|
||||||
|
async def fake_open_connection(rh):
|
||||||
|
return DummyConn()
|
||||||
|
|
||||||
|
async def fake_run_command(conn, command, on_chunk, cancel_event):
|
||||||
|
await on_chunk('stdout', 'doing something\n')
|
||||||
|
return 5 # non-zero
|
||||||
|
|
||||||
|
monkeypatch.setattr(consumers, 'open_connection', fake_open_connection)
|
||||||
|
monkeypatch.setattr(consumers, 'run_command', fake_run_command)
|
||||||
|
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/")
|
||||||
|
comm.scope['user'] = user
|
||||||
|
connected, _ = await comm.connect(); assert connected
|
||||||
|
await comm.receive_json_from() # connected
|
||||||
|
await comm.send_json_to({'action':'start','host_id':host.id,'command':'failingcmd'})
|
||||||
|
msg = await comm.receive_json_from(); assert msg['event']=='started'
|
||||||
|
# drain until completed
|
||||||
|
status = None
|
||||||
|
while True:
|
||||||
|
m = await comm.receive_json_from()
|
||||||
|
if m['event'] == 'completed':
|
||||||
|
status = m['status']; break
|
||||||
|
assert status == 'failed'
|
||||||
|
await comm.disconnect()
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_success(monkeypatch, django_user_model):
|
||||||
|
user = await django_user_model.objects.acreate(username='userb')
|
||||||
|
host = await RemoteHost.objects.acreate(name='hb', hostname='localhost', username='u')
|
||||||
|
batch = await BatchScript.objects.acreate(name='b1', description='d', script='cd /tmp\necho one\necho two\n# comment\necho three')
|
||||||
|
|
||||||
|
from project.remotectl import consumers
|
||||||
|
async def fake_open_connection(rh):
|
||||||
|
return DummyConn()
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
async def fake_run_command(conn, command, on_chunk, cancel_event):
|
||||||
|
calls.append(command)
|
||||||
|
await on_chunk('stdout', 'out\n')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(consumers, 'open_connection', fake_open_connection)
|
||||||
|
monkeypatch.setattr(consumers, 'run_command', fake_run_command)
|
||||||
|
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/")
|
||||||
|
comm.scope['user'] = user
|
||||||
|
connected, _ = await comm.connect(); assert connected
|
||||||
|
await comm.receive_json_from() # connected
|
||||||
|
await comm.send_json_to({'action':'start_batch','host_id':host.id,'batch_id':batch.id})
|
||||||
|
msg = await comm.receive_json_from(); assert msg['event']=='started'
|
||||||
|
step_headers = 0
|
||||||
|
completed = None
|
||||||
|
while True:
|
||||||
|
m = await comm.receive_json_from()
|
||||||
|
if m['event']=='chunk' and m['data'].startswith('\n>>>'):
|
||||||
|
step_headers += 1
|
||||||
|
if m['event']=='completed':
|
||||||
|
completed = m; break
|
||||||
|
assert completed['status']=='ok'
|
||||||
|
# Steps: cd, echo one, echo two, echo three => 4 headers
|
||||||
|
assert step_headers == 5 or step_headers >= 4 # tolerate extra formatting
|
||||||
|
# run_command should have been called for 3 executable echo commands
|
||||||
|
assert len(calls) == 3
|
||||||
|
await comm.disconnect()
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_failure(monkeypatch, django_user_model):
|
||||||
|
user = await django_user_model.objects.acreate(username='userbf')
|
||||||
|
host = await RemoteHost.objects.acreate(name='hbf', hostname='localhost', username='u')
|
||||||
|
batch = await BatchScript.objects.acreate(name='bf', description='d', script='echo ok\necho fail\necho skip')
|
||||||
|
|
||||||
|
from project.remotectl import consumers
|
||||||
|
async def fake_open_connection(rh):
|
||||||
|
return DummyConn()
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
async def fake_run_command(conn, command, on_chunk, cancel_event):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
await on_chunk('stdout', f'run {call_count}\n')
|
||||||
|
if call_count == 2:
|
||||||
|
return 9 # fail second step
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(consumers, 'open_connection', fake_open_connection)
|
||||||
|
monkeypatch.setattr(consumers, 'run_command', fake_run_command)
|
||||||
|
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/")
|
||||||
|
comm.scope['user'] = user
|
||||||
|
connected, _ = await comm.connect(); assert connected
|
||||||
|
await comm.receive_json_from() # connected
|
||||||
|
await comm.send_json_to({'action':'start_batch','host_id':host.id,'batch_id':batch.id})
|
||||||
|
await comm.receive_json_from() # started
|
||||||
|
while True:
|
||||||
|
m = await comm.receive_json_from()
|
||||||
|
if m['event']=='completed':
|
||||||
|
assert m['status'] == 'failed'
|
||||||
|
break
|
||||||
|
assert call_count == 2 # third not executed
|
||||||
|
await comm.disconnect()
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_command(monkeypatch, django_user_model):
|
||||||
|
user = await django_user_model.objects.acreate(username='userc')
|
||||||
|
host = await RemoteHost.objects.acreate(name='hc', hostname='localhost', username='u')
|
||||||
|
|
||||||
|
from project.remotectl import consumers
|
||||||
|
async def fake_open_connection(rh):
|
||||||
|
return DummyConn()
|
||||||
|
|
||||||
|
async def fake_run_command(conn, command, on_chunk, cancel_event):
|
||||||
|
# emit some output repeatedly until canceled
|
||||||
|
for i in range(5):
|
||||||
|
await on_chunk('stdout', f'line {i}\n')
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if cancel_event.is_set():
|
||||||
|
return 130
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(consumers, 'open_connection', fake_open_connection)
|
||||||
|
monkeypatch.setattr(consumers, 'run_command', fake_run_command)
|
||||||
|
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/")
|
||||||
|
comm.scope['user'] = user
|
||||||
|
connected, _ = await comm.connect(); assert connected
|
||||||
|
await comm.receive_json_from() # connected
|
||||||
|
await comm.send_json_to({'action':'start','host_id':host.id,'command':'longrun'})
|
||||||
|
await comm.receive_json_from() # started
|
||||||
|
# send cancel quickly
|
||||||
|
await comm.send_json_to({'action':'cancel'})
|
||||||
|
saw_canceling = False
|
||||||
|
status = None
|
||||||
|
while True:
|
||||||
|
m = await comm.receive_json_from()
|
||||||
|
if m['event']=='canceling':
|
||||||
|
saw_canceling = True
|
||||||
|
if m['event']=='completed':
|
||||||
|
status = m['status']; break
|
||||||
|
assert saw_canceling
|
||||||
|
assert status == 'canceled'
|
||||||
|
await comm.disconnect()
|
||||||
23
project/remotectl/urls.py
Normal file
23
project/remotectl/urls.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.console, name='console'),
|
||||||
|
path('manual/', views.manual, name='manual'),
|
||||||
|
path('readme/', views.readme, name='readme'),
|
||||||
|
path('hosts/', views.host_list, name='host_list'),
|
||||||
|
path('hosts/new/', views.host_create, name='host_create'),
|
||||||
|
path('hosts/<int:pk>/edit/', views.host_edit, name='host_edit'),
|
||||||
|
path('logs/', views.log_list, name='log_list'),
|
||||||
|
path('logs/<int:pk>/', views.log_detail, name='log_detail'),
|
||||||
|
path('batches/', views.batch_list, name='batch_list'),
|
||||||
|
path('batches/new/', views.batch_create, name='batch_create'),
|
||||||
|
path('batches/<int:pk>/edit/', views.batch_edit, name='batch_edit'),
|
||||||
|
path('batches/<int:pk>/delete/', views.batch_delete, name='batch_delete'),
|
||||||
|
path('batches/json/', views.batch_list_json, name='batch_list_json'),
|
||||||
|
path('tasks/', views.task_list, name='task_list'),
|
||||||
|
path('tasks/new/', views.task_create, name='task_create'),
|
||||||
|
path('tasks/<int:pk>/edit/', views.task_edit, name='task_edit'),
|
||||||
|
path('tasks/<int:pk>/delete/', views.task_delete, name='task_delete'),
|
||||||
|
path('tasks/json/', views.task_list_json, name='task_list_json'),
|
||||||
|
]
|
||||||
155
project/remotectl/views.py
Normal file
155
project/remotectl/views.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.http import JsonResponse, Http404
|
||||||
|
from .models import RemoteHost, CommandLog, BatchScript, CommandTask
|
||||||
|
from .forms import RemoteHostForm, BatchScriptForm, CommandTaskForm
|
||||||
|
import uuid, os, markdown
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def host_list(request):
|
||||||
|
hosts = RemoteHost.objects.all()
|
||||||
|
return render(request, 'remotectl/host_list.html', {'hosts': hosts})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def host_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = RemoteHostForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect('host_list')
|
||||||
|
else:
|
||||||
|
form = RemoteHostForm()
|
||||||
|
return render(request, 'remotectl/host_form.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def host_edit(request, pk):
|
||||||
|
host = get_object_or_404(RemoteHost, pk=pk)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = RemoteHostForm(request.POST, instance=host)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect('host_list')
|
||||||
|
else:
|
||||||
|
form = RemoteHostForm(instance=host)
|
||||||
|
return render(request, 'remotectl/host_form.html', {'form': form, 'host': host})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def console(request):
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
hosts = RemoteHost.objects.all()
|
||||||
|
tasks = CommandTask.objects.all()
|
||||||
|
return render(request, 'remotectl/console.html', {'session_id': session_id, 'hosts': hosts, 'tasks': tasks})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def log_list(request):
|
||||||
|
logs = CommandLog.objects.select_related('host','created_by')[:200]
|
||||||
|
return render(request, 'remotectl/log_list.html', {'logs': logs})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def log_detail(request, pk):
|
||||||
|
log = get_object_or_404(CommandLog, pk=pk)
|
||||||
|
return render(request, 'remotectl/log_detail.html', {'log': log})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def batch_list(request):
|
||||||
|
scripts = BatchScript.objects.all()
|
||||||
|
return render(request, 'remotectl/batch_list.html', {'scripts': scripts})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def batch_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = BatchScriptForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
obj.created_by = request.user
|
||||||
|
obj.save()
|
||||||
|
return redirect('batch_list')
|
||||||
|
else:
|
||||||
|
form = BatchScriptForm()
|
||||||
|
return render(request, 'remotectl/batch_form.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def batch_edit(request, pk):
|
||||||
|
script = get_object_or_404(BatchScript, pk=pk)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = BatchScriptForm(request.POST, instance=script)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect('batch_list')
|
||||||
|
else:
|
||||||
|
form = BatchScriptForm(instance=script)
|
||||||
|
return render(request, 'remotectl/batch_form.html', {'form': form, 'script': script})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def batch_delete(request, pk):
|
||||||
|
script = get_object_or_404(BatchScript, pk=pk)
|
||||||
|
if request.method == 'POST':
|
||||||
|
script.delete()
|
||||||
|
return redirect('batch_list')
|
||||||
|
return render(request, 'remotectl/batch_delete.html', {'script': script})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def batch_list_json(request):
|
||||||
|
scripts = list(BatchScript.objects.values('id','name','description','updated_at'))
|
||||||
|
return JsonResponse(scripts, safe=False)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def task_list(request):
|
||||||
|
tasks_db = CommandTask.objects.all()
|
||||||
|
return render(request, 'remotectl/task_list.html', {'tasks_db': tasks_db})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def task_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CommandTaskForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
obj.created_by = request.user
|
||||||
|
obj.save()
|
||||||
|
return redirect('task_list')
|
||||||
|
else:
|
||||||
|
form = CommandTaskForm()
|
||||||
|
return render(request, 'remotectl/task_form.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def task_edit(request, pk):
|
||||||
|
task = get_object_or_404(CommandTask, pk=pk)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CommandTaskForm(request.POST, instance=task)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect('task_list')
|
||||||
|
else:
|
||||||
|
form = CommandTaskForm(instance=task)
|
||||||
|
return render(request, 'remotectl/task_form.html', {'form': form, 'task': task})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def task_delete(request, pk):
|
||||||
|
task = get_object_or_404(CommandTask, pk=pk)
|
||||||
|
if request.method == 'POST':
|
||||||
|
task.delete()
|
||||||
|
return redirect('task_list')
|
||||||
|
return render(request, 'remotectl/task_delete.html', {'task': task})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def task_list_json(request):
|
||||||
|
data = list(CommandTask.objects.values('id','name','label','command'))
|
||||||
|
return JsonResponse(data, safe=False)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def manual(request):
|
||||||
|
return render(request, 'remotectl/manual.html')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def readme(request):
|
||||||
|
base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # project root
|
||||||
|
readme_path = os.path.join(base_path, 'README.md')
|
||||||
|
try:
|
||||||
|
with open(readme_path, 'r', encoding='utf-8') as f:
|
||||||
|
md_text = f.read()
|
||||||
|
except OSError:
|
||||||
|
md_text = '# README.md not found' # fallback
|
||||||
|
html = markdown.markdown(md_text, extensions=['fenced_code', 'tables'])
|
||||||
|
return render(request, 'remotectl/readme.html', {'readme_html': html})
|
||||||
13
pyproject.toml
Normal file
13
pyproject.toml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py312']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
select = ["E","F","I","UP"]
|
||||||
|
ignore = ["E203","E501"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "project.config.settings"
|
||||||
|
python_files = ["tests.py","test_*.py"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
14
requirements.txt
Normal file
14
requirements.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
Django==5.1.1
|
||||||
|
channels==4.1.0
|
||||||
|
daphne==4.0.0
|
||||||
|
asyncssh==2.14.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
# htmx is loaded via CDN in templates; no pip package required
|
||||||
|
uvicorn==0.30.6 # optional alternative server
|
||||||
|
asgiref>=3.8.0
|
||||||
|
black==24.8.0
|
||||||
|
ruff==0.6.5
|
||||||
|
pytest==8.3.2
|
||||||
|
pytest-django==4.8.0
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
markdown==3.7
|
||||||
Loading…
x
Reference in New Issue
Block a user