Harden UI security and add deployment templates
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.ai/
|
.ai/
|
||||||
.codex
|
.codex
|
||||||
|
node_modules/
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -11,6 +11,7 @@
|
|||||||
- guest/admin/master API-ключи
|
- guest/admin/master API-ключи
|
||||||
- event log и базовая статистика
|
- event log и базовая статистика
|
||||||
- встроенный UI в `static/index.html`
|
- встроенный UI в `static/index.html`
|
||||||
|
- встроенный UI без внешних CDN
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ uvicorn main:app --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
UI: `http://<host>:8000/`
|
UI: `http://<host>:8000/`
|
||||||
|
|
||||||
|
Готовые deployment-файлы для `systemd`: [deploy/README.md](/home/kokos/workspace/ignis/ignis-core/deploy/README.md:1)
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
Минимум:
|
Минимум:
|
||||||
@@ -66,6 +69,12 @@ IGNIS_SYNC_DATABASE_URL=sqlite:///./ignis.db
|
|||||||
|
|
||||||
Сервер работает в `fail-closed`: если `IGNIS_API_KEY` не задан, защищённые маршруты недоступны.
|
Сервер работает в `fail-closed`: если `IGNIS_API_KEY` не задан, защищённые маршруты недоступны.
|
||||||
|
|
||||||
|
Встроенный UI:
|
||||||
|
|
||||||
|
- использует только локальные статические ассеты
|
||||||
|
- по умолчанию не сохраняет API-ключ между перезагрузками
|
||||||
|
- может запомнить ключ только в рамках текущей вкладки браузера
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Основные маршруты:
|
Основные маршруты:
|
||||||
@@ -168,7 +177,7 @@ curl -X POST 'http://localhost:8000/schedules/once' \
|
|||||||
timeout 120s .venv/bin/python -m unittest discover -s tests -v
|
timeout 120s .venv/bin/python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Сейчас есть 25 тестов. Покрыты:
|
Сейчас есть 27 тестов. Покрыты:
|
||||||
|
|
||||||
- auth и роли
|
- auth и роли
|
||||||
- lifecycle API-ключей
|
- lifecycle API-ключей
|
||||||
@@ -179,6 +188,7 @@ timeout 120s .venv/bin/python -m unittest discover -s tests -v
|
|||||||
- auto-subnet selection для discovery
|
- auto-subnet selection для discovery
|
||||||
- background offline cleanup threshold
|
- background offline cleanup threshold
|
||||||
- manual rescan summary и immediate cleanup
|
- manual rescan summary и immediate cleanup
|
||||||
|
- security headers и локальные UI-ассеты
|
||||||
- агрегация stats без двойного счёта `*_requested`
|
- агрегация stats без двойного счёта `*_requested`
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|||||||
84
deploy/README.md
Normal file
84
deploy/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Deploy
|
||||||
|
|
||||||
|
Практичный минимум для запуска `ignis-core` как `systemd`-сервиса.
|
||||||
|
|
||||||
|
В папке:
|
||||||
|
|
||||||
|
- `ignis-core.service` -- шаблон unit-файла
|
||||||
|
- `ignis-core.env.example` -- пример переменных окружения
|
||||||
|
|
||||||
|
## Предположения
|
||||||
|
|
||||||
|
Ниже используется такая раскладка:
|
||||||
|
|
||||||
|
- код проекта: `/opt/ignis/ignis-core`
|
||||||
|
- env-файл: `/etc/ignis-core/ignis-core.env`
|
||||||
|
- Unix-пользователь: `ignis`
|
||||||
|
- SQLite БД: `/var/lib/ignis-core/ignis.db`
|
||||||
|
|
||||||
|
Если у вас другие пути, просто поправьте unit и env-файл.
|
||||||
|
|
||||||
|
## 1. Подготовить пользователя
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --home /opt/ignis --shell /usr/sbin/nologin ignis
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Разложить проект
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/ignis
|
||||||
|
sudo cp -R /path/to/ignis-core /opt/ignis/ignis-core
|
||||||
|
cd /opt/ignis/ignis-core
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo chown -R ignis:ignis /opt/ignis
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Подготовить env-файл
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/ignis-core
|
||||||
|
sudo cp deploy/ignis-core.env.example /etc/ignis-core/ignis-core.env
|
||||||
|
sudo chmod 640 /etc/ignis-core/ignis-core.env
|
||||||
|
sudo chown root:ignis /etc/ignis-core/ignis-core.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Что важно заполнить обязательно:
|
||||||
|
|
||||||
|
- `IGNIS_API_KEY`
|
||||||
|
- `SCAN_NETWORK`
|
||||||
|
|
||||||
|
Для домашней сети лучше задавать `SCAN_NETWORK` явно, особенно если на хосте есть VPN или несколько интерфейсов.
|
||||||
|
|
||||||
|
## 4. Установить unit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/ignis-core.service /etc/systemd/system/ignis-core.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now ignis-core.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status ignis-core.service
|
||||||
|
sudo journalctl -u ignis-core.service -n 100 --no-pager
|
||||||
|
curl -H 'X-API-Key: <master-key>' http://127.0.0.1:8000/auth/me
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ignis/ignis-core
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo systemctl restart ignis-core.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Замечания
|
||||||
|
|
||||||
|
- `StateDirectory=ignis-core` в unit создаёт `/var/lib/ignis-core` автоматически.
|
||||||
|
- По умолчанию сервис слушает `0.0.0.0:8000`.
|
||||||
|
- Если нужен reverse proxy, его проще ставить перед `ignis-core`, а сам сервис оставить на локальном порту.
|
||||||
9
deploy/ignis-core.env.example
Normal file
9
deploy/ignis-core.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
IGNIS_API_KEY=change-me
|
||||||
|
APP_TIMEZONE=Asia/Novosibirsk
|
||||||
|
SCAN_NETWORK=192.168.0.0/24
|
||||||
|
DISCOVERY_INTERVAL_SECONDS=600
|
||||||
|
DISCOVERY_BACKGROUND_MISSING_THRESHOLD=2
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EVENT_LOG_RETENTION_DAYS=30
|
||||||
|
IGNIS_DATABASE_URL=sqlite+aiosqlite:////var/lib/ignis-core/ignis.db
|
||||||
|
IGNIS_SYNC_DATABASE_URL=sqlite:////var/lib/ignis-core/ignis.db
|
||||||
24
deploy/ignis-core.service
Normal file
24
deploy/ignis-core.service
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ignis Core FastAPI service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ignis
|
||||||
|
Group=ignis
|
||||||
|
WorkingDirectory=/opt/ignis/ignis-core
|
||||||
|
EnvironmentFile=/etc/ignis-core/ignis-core.env
|
||||||
|
ExecStart=/opt/ignis/ignis-core/.venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=20
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths=/opt/ignis/ignis-core /var/lib/ignis-core
|
||||||
|
StateDirectory=ignis-core
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
31
main.py
31
main.py
@@ -18,6 +18,19 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
|||||||
logging.basicConfig(level=LOG_LEVEL, format="%(asctime)s | %(levelname)s | %(message)s")
|
logging.basicConfig(level=LOG_LEVEL, format="%(asctime)s | %(levelname)s | %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UI_CONTENT_SECURITY_POLICY = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-eval'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"frame-ancestors 'none'; "
|
||||||
|
"form-action 'self'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -50,6 +63,24 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="Ignis Core API", lifespan=lifespan)
|
app = FastAPI(title="Ignis Core API", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_security_headers(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers.setdefault("Cache-Control", "no-store")
|
||||||
|
response.headers.setdefault("Pragma", "no-cache")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Permissions-Policy",
|
||||||
|
"camera=(), geolocation=(), microphone=()",
|
||||||
|
)
|
||||||
|
response.headers.setdefault("Content-Security-Policy", UI_CONTENT_SECURITY_POLICY)
|
||||||
|
return response
|
||||||
|
|
||||||
# Регистрация роутеров
|
# Регистрация роутеров
|
||||||
app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"])
|
app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"])
|
||||||
app.include_router(control.router, prefix="/control", tags=["Control"])
|
app.include_router(control.router, prefix="/control", tags=["Control"])
|
||||||
|
|||||||
483
static/app.js
Normal file
483
static/app.js
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
const SESSION_KEY_NAME = "ignis_session_key";
|
||||||
|
|
||||||
|
function summaryInt(summary, key) {
|
||||||
|
const value = summary?.[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRescanSummary(summary) {
|
||||||
|
const found = summaryInt(summary, "found");
|
||||||
|
const added = summaryInt(summary, "added");
|
||||||
|
const updated = summaryInt(summary, "updated");
|
||||||
|
const removed = summaryInt(summary, "removed_offline");
|
||||||
|
|
||||||
|
if (added === 0 && updated === 0 && removed === 0 && found === 0) {
|
||||||
|
return "Сканирование завершено: устройства не найдены";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added === 0 && removed === 0) {
|
||||||
|
return `Сканирование завершено: найдено ${found}, обновлено ${updated}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Сканирование завершено: найдено ${found}, новых ${added}, обновлено ${updated}, убрано ${removed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createApp } = Vue;
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
data() {
|
||||||
|
const sessionApiKey = sessionStorage.getItem(SESSION_KEY_NAME) || "";
|
||||||
|
return {
|
||||||
|
apiKey: sessionApiKey,
|
||||||
|
tempKey: "",
|
||||||
|
rememberInSession: sessionApiKey.length > 0,
|
||||||
|
tab: "control",
|
||||||
|
isAdmin: false,
|
||||||
|
isMaster: false,
|
||||||
|
authName: "",
|
||||||
|
groups: {},
|
||||||
|
devices: [],
|
||||||
|
sliders: {},
|
||||||
|
newGroup: { id: "", name: "", macs: [] },
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingStatus: false,
|
||||||
|
isFetching: false,
|
||||||
|
isRescanning: false,
|
||||||
|
taskHour: "22",
|
||||||
|
taskMin: "00",
|
||||||
|
newTask: { target_id: "", state: true },
|
||||||
|
tasks: [],
|
||||||
|
allScenes: {},
|
||||||
|
toasts: [],
|
||||||
|
toastCounter: 0,
|
||||||
|
apiKeys: [],
|
||||||
|
newKeyName: "",
|
||||||
|
newKeyAdmin: false,
|
||||||
|
lastCreatedKey: "",
|
||||||
|
statsData: [],
|
||||||
|
eventLog: [],
|
||||||
|
statsDays: 7,
|
||||||
|
refreshTimerId: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
saveKey() {
|
||||||
|
const key = this.tempKey.trim();
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiKey = key;
|
||||||
|
this.tempKey = "";
|
||||||
|
if (this.rememberInSession) {
|
||||||
|
sessionStorage.setItem(SESSION_KEY_NAME, key);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(SESSION_KEY_NAME);
|
||||||
|
}
|
||||||
|
this.initApp();
|
||||||
|
},
|
||||||
|
resetAuthState() {
|
||||||
|
this.apiKey = "";
|
||||||
|
this.tempKey = "";
|
||||||
|
this.isAdmin = false;
|
||||||
|
this.isMaster = false;
|
||||||
|
this.authName = "";
|
||||||
|
this.groups = {};
|
||||||
|
this.devices = [];
|
||||||
|
this.sliders = {};
|
||||||
|
this.tasks = [];
|
||||||
|
this.apiKeys = [];
|
||||||
|
this.statsData = [];
|
||||||
|
this.eventLog = [];
|
||||||
|
this.lastCreatedKey = "";
|
||||||
|
if (this.refreshTimerId !== null) {
|
||||||
|
clearInterval(this.refreshTimerId);
|
||||||
|
this.refreshTimerId = null;
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(SESSION_KEY_NAME);
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
this.resetAuthState();
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
toast(text, type = "info", duration = 3000) {
|
||||||
|
const id = ++this.toastCounter;
|
||||||
|
this.toasts.push({ id, text, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
this.toasts = this.toasts.filter((toast) => toast.id !== id);
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
getGroupName(targetId) {
|
||||||
|
const group = this.groups[targetId];
|
||||||
|
return group ? group.name : null;
|
||||||
|
},
|
||||||
|
async request(path, { method = "GET", query = null, body = null } = {}) {
|
||||||
|
let url = path;
|
||||||
|
if (query) {
|
||||||
|
url += `?${new URLSearchParams(query).toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin",
|
||||||
|
referrerPolicy: "no-referrer",
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": this.apiKey,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
if (
|
||||||
|
err.detail === "Недостаточно прав" ||
|
||||||
|
err.detail === "Требуется мастер-ключ"
|
||||||
|
) {
|
||||||
|
this.toast("Нет прав", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.toast("Неверный API-ключ", "error");
|
||||||
|
this.logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
this.toast(err.detail || `Ошибка ${response.status}`, "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
this.toast("Сервер недоступен", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async initApp() {
|
||||||
|
this.isLoading = true;
|
||||||
|
const auth = await this.request("/auth/me");
|
||||||
|
if (!auth) {
|
||||||
|
this.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isAdmin = auth.is_admin;
|
||||||
|
this.isMaster = Boolean(auth.is_master);
|
||||||
|
this.authName = auth.name;
|
||||||
|
await this.fetchData();
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
if (this.refreshTimerId === null) {
|
||||||
|
this.refreshTimerId = setInterval(() => this.fetchData(), 15000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchData() {
|
||||||
|
if (!this.apiKey || this.isFetching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isFetching = true;
|
||||||
|
try {
|
||||||
|
const [groupsData, devicesData, scenesData] = await Promise.all([
|
||||||
|
this.request("/devices/groups"),
|
||||||
|
this.request("/devices"),
|
||||||
|
this.request("/devices/scenes"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (groupsData) {
|
||||||
|
this.groups = groupsData;
|
||||||
|
Object.keys(this.groups).forEach((id) => {
|
||||||
|
if (!this.sliders[id]) {
|
||||||
|
this.sliders[id] = { brightness: 100, temp: 4000, state: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await this.syncGroupStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devicesData) {
|
||||||
|
this.devices = Array.isArray(devicesData)
|
||||||
|
? devicesData
|
||||||
|
: Object.values(devicesData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenesData) {
|
||||||
|
this.allScenes = scenesData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isAdmin) {
|
||||||
|
await this.fetchTasks();
|
||||||
|
}
|
||||||
|
if (this.isMaster) {
|
||||||
|
await this.fetchApiKeys();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async control(id, params) {
|
||||||
|
await this.request(`/control/group/${id}`, { method: "POST", body: params });
|
||||||
|
},
|
||||||
|
toggleGroup(id, state) {
|
||||||
|
if (this.sliders[id]) {
|
||||||
|
this.sliders[id].state = state;
|
||||||
|
}
|
||||||
|
this.control(id, { state });
|
||||||
|
},
|
||||||
|
setBrightness(id, value) {
|
||||||
|
if (this.sliders[id]) {
|
||||||
|
this.sliders[id].brightness = value;
|
||||||
|
}
|
||||||
|
this.control(id, { brightness: value });
|
||||||
|
},
|
||||||
|
setTemp(id, value) {
|
||||||
|
if (this.sliders[id]) {
|
||||||
|
this.sliders[id].temp = value;
|
||||||
|
}
|
||||||
|
this.control(id, { temp: value });
|
||||||
|
},
|
||||||
|
setScene(id, scene) {
|
||||||
|
this.control(id, { scene });
|
||||||
|
},
|
||||||
|
setColor(id, hex) {
|
||||||
|
this.control(id, {
|
||||||
|
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||||
|
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||||
|
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async createGroup() {
|
||||||
|
const response = await this.request("/devices/groups", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
id: this.newGroup.id,
|
||||||
|
name: this.newGroup.name,
|
||||||
|
macs: this.newGroup.macs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toast(`Группа "${this.newGroup.name}" создана`, "success");
|
||||||
|
this.newGroup = { id: "", name: "", macs: [] };
|
||||||
|
await this.fetchData();
|
||||||
|
this.tab = "control";
|
||||||
|
},
|
||||||
|
async deleteGroup(id) {
|
||||||
|
const name = this.groups[id]?.name || id;
|
||||||
|
if (!confirm(`Удалить группу "${name}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request(`/devices/groups/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toast("Удалена", "success");
|
||||||
|
await this.fetchData();
|
||||||
|
},
|
||||||
|
async rescan() {
|
||||||
|
this.isRescanning = true;
|
||||||
|
try {
|
||||||
|
const response = await this.request("/devices/rescan", { method: "POST" });
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fetchData();
|
||||||
|
this.toast(formatRescanSummary(response), "success");
|
||||||
|
} finally {
|
||||||
|
this.isRescanning = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async blink(id) {
|
||||||
|
await this.request(`/control/device/${id}/blink`, { method: "POST" });
|
||||||
|
},
|
||||||
|
async syncGroupStatuses() {
|
||||||
|
if (this.isLoadingStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoadingStatus = true;
|
||||||
|
try {
|
||||||
|
const ids = Object.keys(this.groups);
|
||||||
|
const results = await Promise.all(
|
||||||
|
ids.map((id) => this.request(`/control/group/${id}/status`)),
|
||||||
|
);
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
const data = results[index];
|
||||||
|
if (!data?.results?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validResult = data.results.find((result) => result.status && !result.error);
|
||||||
|
if (validResult) {
|
||||||
|
this.sliders[id] = {
|
||||||
|
brightness: validResult.status.dimming || 100,
|
||||||
|
temp: validResult.status.temp || 4000,
|
||||||
|
state: validResult.status.state || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.isLoadingStatus = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchTasks() {
|
||||||
|
const data = await this.request("/schedules/tasks");
|
||||||
|
if (data) {
|
||||||
|
this.tasks = data.tasks || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async addSchedule() {
|
||||||
|
if (!this.newTask.target_id) {
|
||||||
|
this.toast("Выберите группу", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request("/schedules/cron", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
target_id: this.newTask.target_id,
|
||||||
|
hour: this.taskHour,
|
||||||
|
minute: this.taskMin,
|
||||||
|
is_group: true,
|
||||||
|
state: this.newTask.state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response) {
|
||||||
|
this.toast(`${this.taskHour}:${this.taskMin} добавлено`, "success");
|
||||||
|
await this.fetchTasks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteTask(id) {
|
||||||
|
const response = await this.request(`/schedules/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (response) {
|
||||||
|
this.toast("Отменено", "success");
|
||||||
|
await this.fetchTasks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setTimer4h(id) {
|
||||||
|
this.toggleGroup(id, true);
|
||||||
|
const response = await this.request("/schedules/once", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
target_id: id,
|
||||||
|
hours_from_now: 4,
|
||||||
|
is_group: true,
|
||||||
|
state: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response) {
|
||||||
|
this.toast("Таймер 4ч", "success");
|
||||||
|
await this.fetchTasks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchApiKeys() {
|
||||||
|
const data = await this.request("/api-keys");
|
||||||
|
if (data) {
|
||||||
|
this.apiKeys = data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createApiKey() {
|
||||||
|
const name = this.newKeyName.trim();
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request("/api-keys", {
|
||||||
|
method: "POST",
|
||||||
|
query: { name, is_admin: this.newKeyAdmin },
|
||||||
|
});
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastCreatedKey = response.key;
|
||||||
|
this.newKeyName = "";
|
||||||
|
this.newKeyAdmin = false;
|
||||||
|
this.toast(`Ключ "${name}" создан`, "success");
|
||||||
|
await this.fetchApiKeys();
|
||||||
|
},
|
||||||
|
async revokeApiKey(key, name) {
|
||||||
|
if (!confirm(`Отозвать ключ "${name}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request("/api-keys/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
body: { key },
|
||||||
|
});
|
||||||
|
if (response) {
|
||||||
|
this.toast(`Ключ "${name}" отозван`, "success");
|
||||||
|
await this.fetchApiKeys();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async activateApiKey(key, name) {
|
||||||
|
const response = await this.request("/api-keys/activate", {
|
||||||
|
method: "POST",
|
||||||
|
body: { key },
|
||||||
|
});
|
||||||
|
if (response) {
|
||||||
|
this.toast(`Ключ "${name}" активирован`, "success");
|
||||||
|
await this.fetchApiKeys();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async copyKey(key) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
this.toast("Скопировано", "success");
|
||||||
|
} catch (_) {
|
||||||
|
this.toast("Не удалось скопировать ключ", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchStats() {
|
||||||
|
const data = await this.request("/stats/summary", {
|
||||||
|
query: { days: this.statsDays },
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
this.statsData = data.groups || [];
|
||||||
|
}
|
||||||
|
await this.fetchEventLog();
|
||||||
|
},
|
||||||
|
async fetchEventLog() {
|
||||||
|
const data = await this.request("/stats/log", {
|
||||||
|
query: { limit: 100 },
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
this.eventLog = data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatTime(iso) {
|
||||||
|
if (!iso) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(iso);
|
||||||
|
const pad = (value) => String(value).padStart(2, "0");
|
||||||
|
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
if (this.apiKey) {
|
||||||
|
await this.initApp();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.refreshTimerId !== null) {
|
||||||
|
clearInterval(this.refreshTimerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).mount("#app");
|
||||||
@@ -5,35 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>IGNIS | Smart Control</title>
|
<title>IGNIS | Smart Control</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>">
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="/static/vendor/tailwindcdn.js"></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="stylesheet" href="/static/ui.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=Outfit:wght@300;400;600;800;900&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root { --bg-deep: #08090c; --bg-card: rgba(20,22,30,0.8); --border-subtle: rgba(255,255,255,0.06); --border-hover: rgba(249,115,22,0.4); --accent: #f97316; --accent-glow: rgba(249,115,22,0.15); }
|
|
||||||
body { background: var(--bg-deep); color: #e2e8f0; font-family: 'Outfit', sans-serif; min-height: 100vh; }
|
|
||||||
body::before { content: ''; position: fixed; inset: 0; z-index: -1; background: radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%), radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%); }
|
|
||||||
.glass { background: var(--bg-card); backdrop-filter: blur(16px); border: 1px solid var(--border-subtle); }
|
|
||||||
.glass:hover { border-color: var(--border-hover); }
|
|
||||||
.active-tab { background: var(--accent); box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15); }
|
|
||||||
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #1e293b; outline: none; transition: opacity 0.2s; }
|
|
||||||
input[type="range"]:disabled { opacity: 0.25; cursor: not-allowed; }
|
|
||||||
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid var(--accent); box-shadow: 0 0 8px rgba(0,0,0,0.4); transition: transform 0.15s; }
|
|
||||||
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
|
|
||||||
input[type="range"]:disabled::-webkit-slider-thumb { border-color: #475569; cursor: not-allowed; transform: none; }
|
|
||||||
.temp-gradient { background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important; }
|
|
||||||
select { appearance: none; background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 1em; }
|
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
|
||||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
|
||||||
.fade-up { animation: fadeUp 0.4s ease-out both; }
|
|
||||||
.toast-enter { animation: fadeUp 0.3s ease-out; }
|
|
||||||
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
||||||
.pulse-on { animation: pulse-dot 2s ease-in-out infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.spinner { animation: spin 0.8s linear infinite; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="max-w-6xl mx-auto p-4 md:p-8">
|
<div id="app" v-cloak class="max-w-6xl mx-auto p-4 md:p-8">
|
||||||
|
|
||||||
<!-- Авторизация -->
|
<!-- Авторизация -->
|
||||||
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4">
|
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4">
|
||||||
@@ -44,7 +20,13 @@
|
|||||||
<h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2>
|
<h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2>
|
||||||
<p class="text-slate-500 text-xs mb-8">API-ключ</p>
|
<p class="text-slate-500 text-xs mb-8">API-ключ</p>
|
||||||
<input v-model="tempKey" type="password" placeholder="X-API-Key" @keyup.enter="saveKey"
|
<input v-model="tempKey" type="password" placeholder="X-API-Key" @keyup.enter="saveKey"
|
||||||
|
autocomplete="off" spellcheck="false"
|
||||||
class="w-full bg-black/40 border border-slate-700/50 p-4 rounded-xl mb-4 focus:border-orange-500 outline-none text-center mono tracking-widest text-sm">
|
class="w-full bg-black/40 border border-slate-700/50 p-4 rounded-xl mb-4 focus:border-orange-500 outline-none text-center mono tracking-widest text-sm">
|
||||||
|
<label class="flex items-center justify-center gap-2 text-xs text-slate-500 mb-4 cursor-pointer">
|
||||||
|
<input v-model="rememberInSession" type="checkbox" class="w-4 h-4 accent-orange-500 rounded">
|
||||||
|
Запомнить только в этой вкладке
|
||||||
|
</label>
|
||||||
|
<p class="text-[11px] text-slate-600 mb-4">По умолчанию ключ хранится только в памяти текущей страницы.</p>
|
||||||
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]">ВОЙТИ</button>
|
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]">ВОЙТИ</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +67,7 @@
|
|||||||
<p v-else class="text-slate-500">Нет доступных групп</p>
|
<p v-else class="text-slate-500">Нет доступных групп</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="(group, id) in groups" :key="id" class="glass p-6 rounded-2xl transition-all fade-up" :style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
|
<div v-for="(group, id) in groups" :key="id" class="glass p-6 rounded-2xl transition-all fade-up" :class="sliders[id]?.state ? 'group-active' : ''">
|
||||||
<div class="flex justify-between items-start mb-6">
|
<div class="flex justify-between items-start mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-black text-white flex items-center gap-2">
|
<h2 class="text-xl font-black text-white flex items-center gap-2">
|
||||||
@@ -353,186 +335,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/static/vendor/vue.global.prod.js"></script>
|
||||||
const { createApp } = Vue
|
<script src="/static/app.js"></script>
|
||||||
createApp({
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
apiKey: localStorage.getItem('ignis_key') || '',
|
|
||||||
tempKey: '',
|
|
||||||
tab: 'control',
|
|
||||||
isAdmin: false,
|
|
||||||
isMaster: false,
|
|
||||||
authName: '',
|
|
||||||
groups: {}, devices: [], sliders: {},
|
|
||||||
newGroup: { id: '', name: '', macs: [] },
|
|
||||||
isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false,
|
|
||||||
taskHour: '22', taskMin: '00',
|
|
||||||
newTask: { target_id: '', state: true },
|
|
||||||
tasks: [], allScenes: {},
|
|
||||||
toasts: [], toastCounter: 0,
|
|
||||||
apiKeys: [], newKeyName: '', newKeyAdmin: false, lastCreatedKey: '',
|
|
||||||
statsData: [], eventLog: [], statsDays: 7,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
saveKey() {
|
|
||||||
if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
|
|
||||||
},
|
|
||||||
logout() { this.apiKey = ''; this.isAdmin = false; this.isMaster = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); },
|
|
||||||
toast(text, type = 'info', duration = 3000) {
|
|
||||||
const id = ++this.toastCounter; this.toasts.push({ id, text, type });
|
|
||||||
setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration);
|
|
||||||
},
|
|
||||||
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
|
|
||||||
|
|
||||||
async request(path, { method = 'GET', query = null, body = null } = {}) {
|
|
||||||
let url = path;
|
|
||||||
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
|
|
||||||
if (r.status === 403) {
|
|
||||||
const err = await r.json().catch(() => ({}));
|
|
||||||
if (err.detail === 'Недостаточно прав' || err.detail === 'Требуется мастер-ключ') { this.toast('Нет прав', 'error'); return null; }
|
|
||||||
this.toast('Неверный API-ключ', 'error'); this.logout(); return null;
|
|
||||||
}
|
|
||||||
if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; }
|
|
||||||
return await r.json();
|
|
||||||
} catch (e) { this.toast('Сервер недоступен', 'error'); return null; }
|
|
||||||
},
|
|
||||||
|
|
||||||
async initApp() {
|
|
||||||
this.isLoading = true;
|
|
||||||
const auth = await this.request('/auth/me');
|
|
||||||
if (!auth) { this.isLoading = false; return; }
|
|
||||||
this.isAdmin = auth.is_admin;
|
|
||||||
this.isMaster = !!auth.is_master;
|
|
||||||
this.authName = auth.name;
|
|
||||||
await this.fetchData();
|
|
||||||
this.isLoading = false;
|
|
||||||
setInterval(() => this.fetchData(), 15000);
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchData() {
|
|
||||||
if (!this.apiKey || this.isFetching) return;
|
|
||||||
this.isFetching = true;
|
|
||||||
try {
|
|
||||||
const [gData, dData, sData] = await Promise.all([
|
|
||||||
this.request('/devices/groups'), this.request('/devices'), this.request('/devices/scenes'),
|
|
||||||
]);
|
|
||||||
if (gData) {
|
|
||||||
this.groups = gData;
|
|
||||||
Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; });
|
|
||||||
await this.syncGroupStatuses();
|
|
||||||
}
|
|
||||||
if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
|
|
||||||
if (sData) this.allScenes = sData;
|
|
||||||
if (this.isAdmin) this.fetchTasks();
|
|
||||||
if (this.isMaster) this.fetchApiKeys();
|
|
||||||
} finally { this.isFetching = false; }
|
|
||||||
},
|
|
||||||
|
|
||||||
async control(id, params) { await this.request(`/control/group/${id}`, { method: 'POST', body: params }); },
|
|
||||||
toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); },
|
|
||||||
setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); },
|
|
||||||
setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); },
|
|
||||||
setScene(id, scene) { this.control(id, { scene }); },
|
|
||||||
setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); },
|
|
||||||
|
|
||||||
async createGroup() {
|
|
||||||
const res = await this.request('/devices/groups', { method: 'POST', body: { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs } });
|
|
||||||
if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; }
|
|
||||||
},
|
|
||||||
async deleteGroup(id) {
|
|
||||||
const name = this.groups[id]?.name || id;
|
|
||||||
if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, { method: 'DELETE' }); this.toast(`Удалена`, 'success'); await this.fetchData(); }
|
|
||||||
},
|
|
||||||
async rescan() {
|
|
||||||
this.isRescanning = true; await this.request('/devices/rescan', { method: 'POST' }); this.toast('Сканирование...', 'info');
|
|
||||||
setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000);
|
|
||||||
},
|
|
||||||
async blink(id) { await this.request(`/control/device/${id}/blink`, { method: 'POST' }); },
|
|
||||||
|
|
||||||
async syncGroupStatuses() {
|
|
||||||
if (this.isLoadingStatus) return; this.isLoadingStatus = true;
|
|
||||||
try {
|
|
||||||
const ids = Object.keys(this.groups);
|
|
||||||
const results = await Promise.all(ids.map(id => this.request(`/control/group/${id}/status`)));
|
|
||||||
ids.forEach((id, i) => {
|
|
||||||
const d = results[i];
|
|
||||||
if (d?.results?.length > 0) {
|
|
||||||
const v = d.results.find(r => r.status && !r.error);
|
|
||||||
if (v) this.sliders[id] = { brightness: v.status.dimming || 100, temp: v.status.temp || 4000, state: v.status.state || false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally { this.isLoadingStatus = false; }
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; },
|
|
||||||
async addSchedule() {
|
|
||||||
if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; }
|
|
||||||
const res = await this.request('/schedules/cron', { method: 'POST', body: { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state } });
|
|
||||||
if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); }
|
|
||||||
},
|
|
||||||
async deleteTask(id) { await this.request(`/schedules/${id}`, { method: 'DELETE' }); this.toast('Отменено', 'success'); this.fetchTasks(); },
|
|
||||||
async setTimer4h(id) {
|
|
||||||
await this.toggleGroup(id, true);
|
|
||||||
const res = await this.request('/schedules/once', { method: 'POST', body: { target_id: id, hours_from_now: 4, is_group: true, state: false } });
|
|
||||||
if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); }
|
|
||||||
},
|
|
||||||
|
|
||||||
// ─── API-ключи ───────────────────────────────
|
|
||||||
async fetchApiKeys() {
|
|
||||||
const data = await this.request('/api-keys');
|
|
||||||
if (data) this.apiKeys = data;
|
|
||||||
},
|
|
||||||
async createApiKey() {
|
|
||||||
const name = this.newKeyName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const res = await this.request('/api-keys', { method: 'POST', query: { name, is_admin: this.newKeyAdmin } });
|
|
||||||
if (res) {
|
|
||||||
this.lastCreatedKey = res.key;
|
|
||||||
this.newKeyName = '';
|
|
||||||
this.newKeyAdmin = false;
|
|
||||||
this.toast(`Ключ "${name}" создан`, 'success');
|
|
||||||
this.fetchApiKeys();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async revokeApiKey(key, name) {
|
|
||||||
if (confirm(`Отозвать ключ "${name}"?`)) {
|
|
||||||
await this.request('/api-keys/revoke', { method: 'POST', body: { key } });
|
|
||||||
this.toast(`Ключ "${name}" отозван`, 'success');
|
|
||||||
this.fetchApiKeys();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async activateApiKey(key, name) {
|
|
||||||
await this.request('/api-keys/activate', { method: 'POST', body: { key } });
|
|
||||||
this.toast(`Ключ "${name}" активирован`, 'success');
|
|
||||||
this.fetchApiKeys();
|
|
||||||
},
|
|
||||||
copyKey(key) {
|
|
||||||
navigator.clipboard.writeText(key).then(() => this.toast('Скопировано', 'success'));
|
|
||||||
},
|
|
||||||
|
|
||||||
// ─── Статистика ──────────────────────────────
|
|
||||||
async fetchStats() {
|
|
||||||
const data = await this.request(`/stats/summary`, { query: { days: this.statsDays } });
|
|
||||||
if (data) this.statsData = data.groups || [];
|
|
||||||
await this.fetchEventLog();
|
|
||||||
},
|
|
||||||
async fetchEventLog() {
|
|
||||||
const data = await this.request('/stats/log', { query: { limit: 100 } });
|
|
||||||
if (data) this.eventLog = data;
|
|
||||||
},
|
|
||||||
formatTime(iso) {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
return `${pad(d.getDate())}.${pad(d.getMonth()+1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async mounted() { if (this.apiKey) await this.initApp(); }
|
|
||||||
}).mount('#app')
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
163
static/ui.css
Normal file
163
static/ui.css
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
:root {
|
||||||
|
--bg-deep: #08090c;
|
||||||
|
--bg-card: rgba(20, 22, 30, 0.8);
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-hover: rgba(249, 115, 22, 0.4);
|
||||||
|
--accent: #f97316;
|
||||||
|
--accent-glow: rgba(249, 115, 22, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[v-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family:
|
||||||
|
"Avenir Next",
|
||||||
|
"Segoe UI",
|
||||||
|
"Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 20% 0%,
|
||||||
|
rgba(249, 115, 22, 0.06) 0%,
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 80% 100%,
|
||||||
|
rgba(30, 27, 75, 0.15) 0%,
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-active {
|
||||||
|
border-color: rgba(249, 115, 22, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-tab {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 24px var(--accent-glow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #1e293b;
|
||||||
|
outline: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 3px solid var(--accent);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]:disabled::-webkit-slider-thumb {
|
||||||
|
border-color: #475569;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-gradient {
|
||||||
|
background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family:
|
||||||
|
"JetBrains Mono",
|
||||||
|
"SFMono-Regular",
|
||||||
|
"Cascadia Code",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-up {
|
||||||
|
animation: fadeUp 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter {
|
||||||
|
animation: fadeUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-on {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
83
static/vendor/tailwindcdn.js
vendored
Normal file
83
static/vendor/tailwindcdn.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
static/vendor/vue.global.prod.js
vendored
Normal file
9
static/vendor/vue.global.prod.js
vendored
Normal file
File diff suppressed because one or more lines are too long
52
tests/test_p1_ui_security.py
Normal file
52
tests/test_p1_ui_security.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
MASTER_KEY = "master-secret-for-ui-tests"
|
||||||
|
os.environ["IGNIS_API_KEY"] = MASTER_KEY
|
||||||
|
|
||||||
|
import main # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class UiSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.client = AsyncClient(
|
||||||
|
transport=ASGITransport(app=main.app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def test_root_sets_security_headers(self):
|
||||||
|
response = await self.client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
self.assertEqual(response.headers["pragma"], "no-cache")
|
||||||
|
self.assertEqual(response.headers["referrer-policy"], "no-referrer")
|
||||||
|
self.assertEqual(response.headers["x-content-type-options"], "nosniff")
|
||||||
|
self.assertEqual(response.headers["x-frame-options"], "DENY")
|
||||||
|
self.assertIn("default-src 'self'", response.headers["content-security-policy"])
|
||||||
|
self.assertIn("script-src 'self' 'unsafe-eval'", response.headers["content-security-policy"])
|
||||||
|
self.assertIn(
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
response.headers["content-security-policy"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_static_ui_uses_only_local_assets_and_session_storage(self):
|
||||||
|
index_html = Path("static/index.html").read_text(encoding="utf-8")
|
||||||
|
app_js = Path("static/app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('/static/vendor/tailwindcdn.js', index_html)
|
||||||
|
self.assertIn('/static/ui.css', index_html)
|
||||||
|
self.assertIn('/static/vendor/vue.global.prod.js', index_html)
|
||||||
|
self.assertIn('/static/app.js', index_html)
|
||||||
|
self.assertNotIn("https://unpkg.com", index_html)
|
||||||
|
self.assertNotIn("https://cdn.tailwindcss.com", index_html)
|
||||||
|
self.assertNotIn("https://fonts.googleapis.com", index_html)
|
||||||
|
self.assertNotIn("localStorage", index_html)
|
||||||
|
self.assertNotIn("localStorage", app_js)
|
||||||
|
self.assertIn("sessionStorage", app_js)
|
||||||
Reference in New Issue
Block a user