обавлен вывод температуры и облачности с weatherapi.com, настройки (api_key, lat, lon, cache_ttl) вынесены в config.yaml, поддержка настройки времени кэширования погоды

This commit is contained in:
Vlad 2025-07-11 11:54:23 +03:00
commit e89fd52a89
4 changed files with 454 additions and 0 deletions

18
changelog.md Normal file
View File

@ -0,0 +1,18 @@
# Changelog
## [feature] Онлайн-редактирование config.yaml через веб-интерфейс
- Добавлены API-эндпоинты /api/config (GET, POST) для получения и сохранения настроек
- На главной странице добавлена кнопка "Редактировать настройки" с модальным окном для редактирования config.yaml
- Реализована валидация yaml при сохранении настроек
- После сохранения настроек происходит их немедленное применение
- Удалена старая кнопка перехода к настройкам
- Добавлены тесты на pytest для проверки работы API (чтение, сохранение валидного и невалидного yaml, обновление файла)
- Встроен Monaco Editor для YAML с подсветкой синтаксиса, проверкой ошибок и возможностью увеличивать окно редактора
- Добавлен вывод температуры и облачности с weatherapi.com, настройки (api_key, lat, lon, cache_ttl) вынесены в config.yaml
- Поддержка настройки времени кэширования погоды через weather.cache_ttl (минуты, по умолчанию 60)
## [init] Стартовая инициализация структуры Flask-приложения
- Создана структура каталогов: templates/, static/
- Вынесены данные Applications и Bookmarks в config.yaml
- Добавлен базовый шаблон для главной страницы
- Перенесены ассеты из example/home_files в static/

181
config.yaml Normal file
View File

@ -0,0 +1,181 @@
applications:
- name: XPenelogy
description: NAS
url: https://192.168.1.122:5001/
icon_name: server
- name: wg_pannel
description: vpn
url: http://74.48.138.132:59877/
icon_name: shield
- name: mino
description: minio
url: https://minio.ddl.su/
icon_name: database
- name: PiHole X86
description: PiHole X86
url: https://192.168.1.11/admin
icon_name: activity
- name: Ngnix Proxy
description: Ngnix proxy manager
url: https://npm.ddl.su/
icon_name: repeat
- name: Bit
description: Bitwarder
url: https://bit.ddl.su/#/login
icon_name: lock
- name: Router
description: Router main
url: http://192.168.1.1/
icon_name: wifi
- name: Portainer
description: Portainer
url: https://portainer.ddl.su/
icon_name: box
- name: Uptime
description: 192.168.1.222:3001/dashboard
url: http://192.168.1.222:3001/dashboard
icon_name: clock
- name: Gitlab
description: git
url: https://gitlab.ddl.su/
icon_name: git-branch
- name: Grafana
description: 192.168.1.143:3000
url: http://192.168.1.143:3000/
icon_name: bar-chart-2
- name: PVE1
description: 192.168.1.201:8006
url: https://192.168.1.201:8006/
icon_name: cpu
- name: Docker log service
description: 192.168.1.222:9999
url: http://192.168.1.222:9999/
icon_name: file-text
- name: ha
description: 192.168.1.88:8123
url: http://192.168.1.88:8123/
icon_name: home
- name: mywiki
description: mywiki
url: https://wiki.ddl.su/
icon_name: book-open
- name: VPN panel
description: panel.ddl.su
url: https://panel.ddl.su/
icon_name: key
- name: Prometeus
description: 192.168.1.143:9090
url: http://192.168.1.143:9090/
icon_name: activity
- name: filebrowser
description: FileBrowser
url: http://st.ddl.su/
icon_name: folder
- name: cron
description: cron
url: http://192.168.1.112:9000/
icon_name: repeat
- name: Mikrotik Graphana
description: 192.168.1.148:3000
url: http://192.168.1.148:3000/
icon_name: bar-chart
- name: QS
description: Qnap work
url: https://qs.ddl.su/
icon_name: hard-drive
bookmarks:
- group: Hm
links:
- name: Gmail
url: https://mail.google.com/mail/u/0/?pli=1#inbox
icon_name: mail
- name: SynologyMail
url: https://192.168.1.122:5001/?launchApp=SYNO.SDS.MailClient.Application&SynoToken=S4WX9.Qjfum4w#inbox
icon_name: mail
- name: Yandex Mail
url: https://mail.yandex.ru/
icon_name: mail
- name: Youtube
url: https://youtube.com/
icon_name: youtube
- name: myip
url: http://192.168.1.90:8732/
icon_name: globe
- group: Магазины
links:
- name: AVITO
url: https://www.avito.ru/
icon_name: shopping-bag
- name: AliExpress
url: https://www.aliexpress.com/
icon_name: shopping-cart
- name: OZON
url: https://www.ozon.ru/
icon_name: shopping-cart
- name: Yandex Market
url: https://market.yandex.ru/
icon_name: shopping-cart
- name: auto.ru
url: http://auto.ru/
icon_name: truck
- group: Work
links:
- name: COPART
url: https://www.copart.com/
icon_name: briefcase
- name: IAAI
url: https://www.iaai.com/
icon_name: briefcase
- group: 3D
links:
- name: cults3d
url: https://cults3d.com/
icon_name: cube
- name: printables
url: https://www.printables.com/
icon_name: cube
- name: thingiverse
url: https://www.thingiverse.com/
icon_name: cube
- group: VM
links:
- name: whatsapp
url: https://web.whatsapp.com/
icon_name: message-circle
- group: DWH
links:
- name: 3ui gui
url: https://192.168.1.129:9443/
icon_name: grid
- group: Salvagedb
links:
- name: Goaccess
url: http://185.87.192.205:6784/report.html
icon_name: bar-chart
- name: adsense
url: https://www.google.com/adsense/new/u/1/pub-2964481252074108/home
icon_name: dollar-sign
- name: analytics
url: https://metrika.yandex.ru/overview?id=99030590&period=today&group=hour&isMinSamplingEnabled=false
icon_name: pie-chart
- name: ngnix dashboard
url: http://192.168.1.143:3000/d/MsjffzSZz/nginx?orgId=1&refresh=5s&from=now-24h&to=now
icon_name: activity
- name: Salvagedb
url: https://salvagedb.com/
icon_name: database
- group: VPN
links:
- name: 3ui gui
url: https://vip.salvagedb.com:59332/V8rzsW2LxuCZ5zI/
icon_name: shield
- name: Amnezia Panel
url: https://vip.salvagedb.com:7443/
icon_name: shield-off
weather:
api_key: "8e548386d3c6492f8ef220308231903"
lat: 55.751244
lon: 37,618423
cache_ttl: 60

89
main.py Normal file
View File

@ -0,0 +1,89 @@
from flask import Flask, render_template, request, jsonify, make_response
import yaml
from datetime import datetime
import os
import requests
import time
app = Flask(__name__)
CONFIG_PATH = 'config.yaml'
# Кэш для погоды
weather_cache = {'data': None, 'ts': 0}
def load_config():
with open(CONFIG_PATH, encoding='utf-8') as f:
return yaml.safe_load(f)
def save_config(yaml_text):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
f.write(yaml_text)
def get_weather_cache_ttl():
config = load_config()
w = config.get('weather', {})
ttl = w.get('cache_ttl')
try:
return int(ttl) * 60 if ttl else 3600
except Exception:
return 3600
def get_weather():
global weather_cache
now = time.time()
WEATHER_CACHE_TTL = get_weather_cache_ttl()
if weather_cache['data'] and now - weather_cache['ts'] < WEATHER_CACHE_TTL:
return weather_cache['data']
config = load_config()
w = config.get('weather', {})
api_key = w.get('api_key')
lat = w.get('lat')
lon = w.get('lon')
if not api_key or not lat or not lon:
return None
url = f"https://api.weatherapi.com/v1/current.json?key={api_key}&q={lat},{lon}&lang=ru"
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
data = resp.json()
temp = data['current']['temp_c']
cloud = data['current']['cloud']
result = {'temp': temp, 'cloud': cloud}
weather_cache = {'data': result, 'ts': now}
return result
except Exception:
return None
@app.route('/api/config', methods=['GET'])
def get_config():
try:
with open(CONFIG_PATH, encoding='utf-8') as f:
content = f.read()
return jsonify({'content': content})
except Exception as e:
return make_response(jsonify({'error': str(e)}), 500)
@app.route('/api/config', methods=['POST'])
def update_config():
data = request.get_json()
yaml_text = data.get('content', '')
try:
# Проверка валидности yaml
yaml.safe_load(yaml_text)
save_config(yaml_text)
return jsonify({'status': 'ok'})
except Exception as e:
return make_response(jsonify({'error': str(e)}), 400)
config = load_config()
@app.route('/')
def index():
now = datetime.now().strftime('%A, %d %B %Y - %H:%M:%S')
weather = get_weather()
return render_template('index.html', applications=config['applications'], bookmarks=config['bookmarks'], now=now, weather=weather)
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

166
templates/index.html Normal file
View File

@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Домашняя страница</title>
<link rel="icon" href="/static/favicon.ico">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/static/main.289a6408.css">
<link rel="stylesheet" href="/static/flame.css">
<script src="https://unpkg.com/feather-icons"></script>
</head>
<body style="--color-primary: #FFFDEA; --color-accent: #5c5c5c; --color-background: #1a1a1a;">
<div id="root">
<div class="Layout_Container__HIHX7">
<div><input type="text" class="SearchBar_SearchBar__MQiwu" placeholder="Поиск..."></div>
<header class="Header_Header__GCJdR">
<p>{{ now }}</p>
<a class="Header_SettingsLink__9QdDu" href="/settings">Go to Settings</a>
<span class="Header_HeaderMain__oLoBB">
<h1>Good morning!</h1>
<div class="WeatherWidget_WeatherWidget__3XlYt">
<div><canvas id="weather-icon" width="50" height="50"></canvas></div>
<div class="WeatherWidget_WeatherDetails__1y3CA">
{% if weather %}
<span>{{ weather.temp }}°C</span>
<span>{{ weather.cloud }}% облачность</span>
{% else %}
<span>--°C</span>
<span>--% облачность</span>
{% endif %}
</div>
</div>
</span>
</header>
<a href="/applications"><h2 class="SectionHeadline_SectionHeadline__xa0oJ">Applications</h2></a>
<div class="AppGrid_AppGrid__ZxiaC">
{% for app in applications %}
<a href="{{ app.url }}" target="_blank" rel="noreferrer" class="AppCard_AppCard__NPTM5">
<div class="AppCard_AppCardIcon__ThrUl">
<i data-feather="{{ app.icon_name }}" class="AppCard_CustomIcon__+Tc3I" width="32" height="32"></i>
</div>
<div class="AppCard_AppCardDetails__HgNoY">
<h5>{{ app.name }}</h5>
<span>{{ app.description }}</span>
</div>
</a>
{% endfor %}
</div>
<div class="Home_HomeSpace__--hVj"></div>
<a href="/bookmarks"><h2 class="SectionHeadline_SectionHeadline__xa0oJ">Bookmarks</h2></a>
<div class="BookmarkGrid_BookmarkGrid__i3Tj4">
{% for group in bookmarks %}
<div class="BookmarkCard_BookmarkCard__ooGTe">
<h3 class="">{{ group.group }}</h3>
<div class="BookmarkCard_Bookmarks__alLCe">
{% for link in group.links %}
<a href="{{ link.url }}" target="_blank" rel="noreferrer">
<div class="BookmarkCard_BookmarkIcon__yWkUU">
<i data-feather="{{ link.icon_name }}" class="BookmarkCard_CustomIcon__D7Xys" width="16" height="16"></i>
</div>{{ link.name }}
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<a class="Home_SettingsButton__DrUPz" href="#" id="edit-config-btn">
<svg viewBox="0 0 24 24" role="presentation" class="Icon_Icon__jzcrU"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z" style="fill: var(--color-background);"></path></svg>
<span style="margin-left:8px;">Редактировать настройки</span>
</a>
</div>
<div id="config-modal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100vw; height:100vh; background:rgba(0,0,0,0.7); align-items:center; justify-content:center;">
<div id="config-modal-content" style="background:#fff; color:#222; padding:24px; border-radius:8px; min-width:320px; max-width:90vw; max-height:90vh; display:flex; flex-direction:column; gap:12px; resize:both; overflow:auto; width:60vw; height:60vh;">
<h2>Редактировать config.yaml</h2>
<div style="flex:1 1 auto; display:flex; flex-direction:column; min-height:200px;">
<div id="monaco-editor" style="flex:1 1 auto; min-width:300px; min-height:200px; max-width:100%; max-height:100%; height:100%; border:1px solid #ccc;"></div>
</div>
<div style="display:flex; gap:16px; margin-top:8px;">
<button id="save-config-btn" style="padding:10px 24px; font-size:1.1em;">Сохранить</button>
<button id="cancel-config-btn" style="padding:10px 24px; font-size:1.1em;">Отмена</button>
</div>
<div id="config-error" style="color:red;"></div>
</div>
</div>
<div class="NotificationCenter_NotificationCenter__Tfgzh" style="height: 0px;"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs/loader.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
<script>feather.replace()</script>
<script>
let monacoEditor;
const editBtn = document.getElementById('edit-config-btn');
const modal = document.getElementById('config-modal');
const modalContent = document.getElementById('config-modal-content');
const saveBtn = document.getElementById('save-config-btn');
const cancelBtn = document.getElementById('cancel-config-btn');
const errorDiv = document.getElementById('config-error');
function resizeMonaco() {
if (monacoEditor) {
const editorDiv = document.getElementById('monaco-editor');
const rect = editorDiv.getBoundingClientRect();
monacoEditor.layout({ width: rect.width, height: rect.height });
}
}
// Drag/resize поддерживается через CSS (resize:both)
editBtn.onclick = function(e) {
e.preventDefault();
fetch('/api/config').then(r => r.json()).then(data => {
errorDiv.textContent = '';
modal.style.display = 'flex';
require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
if (monacoEditor) {
monacoEditor.setValue(data.content);
setTimeout(resizeMonaco, 100);
} else {
monacoEditor = monaco.editor.create(document.getElementById('monaco-editor'), {
value: data.content,
language: 'yaml',
theme: 'vs-dark',
automaticLayout: false,
minimap: { enabled: false },
});
setTimeout(resizeMonaco, 100);
}
});
});
};
cancelBtn.onclick = function() {
modal.style.display = 'none';
};
saveBtn.onclick = function() {
const yamlText = monacoEditor.getValue();
try {
window.jsyaml.load(yamlText); // Проверка синтаксиса
fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: yamlText})
}).then(r => r.json().then(data => ({ok: r.ok, data}))).then(res => {
if(res.ok) {
modal.style.display = 'none';
location.reload();
} else {
errorDiv.textContent = res.data.error || 'Ошибка сохранения';
}
});
} catch (e) {
errorDiv.textContent = 'Ошибка YAML: ' + e.message;
}
};
// Следим за изменением размера окна редактора
let resizeObserver = new ResizeObserver(() => {
resizeMonaco();
});
resizeObserver.observe(document.getElementById('config-modal-content'));
</script>
</body>
</html>