Spaces:
Paused
Paused
epii-1
commited on
Commit
·
6a2845d
1
Parent(s):
d65a9cb
12345
Browse files- .idea/.gitignore +8 -0
- .idea/inspectionProfiles/Project_Default.xml +34 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/misc.xml +4 -0
- .idea/modules.xml +8 -0
- .idea/panel.iml +23 -0
- .idea/vcs.xml +6 -0
- Dockerfile +23 -0
- api.py +133 -0
- app.py +252 -0
- config.py +12 -0
- templates/action_result.html +97 -0
- templates/dashboard.html +411 -0
- templates/index.html +135 -0
.idea/.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 默认忽略的文件
|
| 2 |
+
/shelf/
|
| 3 |
+
/workspace.xml
|
| 4 |
+
# 基于编辑器的 HTTP 客户端请求
|
| 5 |
+
/httpRequests/
|
| 6 |
+
# Datasource local storage ignored files
|
| 7 |
+
/dataSources/
|
| 8 |
+
/dataSources.local.xml
|
.idea/inspectionProfiles/Project_Default.xml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<component name="InspectionProjectProfileManager">
|
| 2 |
+
<profile version="1.0">
|
| 3 |
+
<option name="myName" value="Project Default" />
|
| 4 |
+
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
| 5 |
+
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
| 6 |
+
<option name="ignoredPackages">
|
| 7 |
+
<value>
|
| 8 |
+
<list size="20">
|
| 9 |
+
<item index="0" class="java.lang.String" itemvalue="httpx" />
|
| 10 |
+
<item index="1" class="java.lang.String" itemvalue="inquirer" />
|
| 11 |
+
<item index="2" class="java.lang.String" itemvalue="better_proxy" />
|
| 12 |
+
<item index="3" class="java.lang.String" itemvalue="curl_cffi" />
|
| 13 |
+
<item index="4" class="java.lang.String" itemvalue="art" />
|
| 14 |
+
<item index="5" class="java.lang.String" itemvalue="pydantic" />
|
| 15 |
+
<item index="6" class="java.lang.String" itemvalue="paddleocr" />
|
| 16 |
+
<item index="7" class="java.lang.String" itemvalue="PyYAML" />
|
| 17 |
+
<item index="8" class="java.lang.String" itemvalue="aiofiles" />
|
| 18 |
+
<item index="9" class="java.lang.String" itemvalue="rich" />
|
| 19 |
+
<item index="10" class="java.lang.String" itemvalue="numpy" />
|
| 20 |
+
<item index="11" class="java.lang.String" itemvalue="loguru" />
|
| 21 |
+
<item index="12" class="java.lang.String" itemvalue="imap_tools" />
|
| 22 |
+
<item index="13" class="java.lang.String" itemvalue="paddlepaddle" />
|
| 23 |
+
<item index="14" class="java.lang.String" itemvalue="names" />
|
| 24 |
+
<item index="15" class="java.lang.String" itemvalue="tortoise-orm" />
|
| 25 |
+
<item index="16" class="java.lang.String" itemvalue="colorama" />
|
| 26 |
+
<item index="17" class="java.lang.String" itemvalue="pytz" />
|
| 27 |
+
<item index="18" class="java.lang.String" itemvalue="urllib3" />
|
| 28 |
+
<item index="19" class="java.lang.String" itemvalue="aiocsv" />
|
| 29 |
+
</list>
|
| 30 |
+
</value>
|
| 31 |
+
</option>
|
| 32 |
+
</inspection_tool>
|
| 33 |
+
</profile>
|
| 34 |
+
</component>
|
.idea/inspectionProfiles/profiles_settings.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<component name="InspectionProjectProfileManager">
|
| 2 |
+
<settings>
|
| 3 |
+
<option name="USE_PROJECT_PROFILE" value="false" />
|
| 4 |
+
<version value="1.0" />
|
| 5 |
+
</settings>
|
| 6 |
+
</component>
|
.idea/misc.xml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="ProjectRootManager" version="2" project-jdk-name="PPX" project-jdk-type="Python SDK" />
|
| 4 |
+
</project>
|
.idea/modules.xml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="ProjectModuleManager">
|
| 4 |
+
<modules>
|
| 5 |
+
<module fileurl="file://$PROJECT_DIR$/.idea/panel.iml" filepath="$PROJECT_DIR$/.idea/panel.iml" />
|
| 6 |
+
</modules>
|
| 7 |
+
</component>
|
| 8 |
+
</project>
|
.idea/panel.iml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<module type="PYTHON_MODULE" version="4">
|
| 3 |
+
<component name="Flask">
|
| 4 |
+
<option name="enabled" value="true" />
|
| 5 |
+
</component>
|
| 6 |
+
<component name="NewModuleRootManager">
|
| 7 |
+
<content url="file://$MODULE_DIR$" />
|
| 8 |
+
<orderEntry type="inheritedJdk" />
|
| 9 |
+
<orderEntry type="sourceFolder" forTests="false" />
|
| 10 |
+
</component>
|
| 11 |
+
<component name="PyDocumentationSettings">
|
| 12 |
+
<option name="format" value="PLAIN" />
|
| 13 |
+
<option name="myDocStringFormat" value="Plain" />
|
| 14 |
+
</component>
|
| 15 |
+
<component name="TemplatesService">
|
| 16 |
+
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
| 17 |
+
<option name="TEMPLATE_FOLDERS">
|
| 18 |
+
<list>
|
| 19 |
+
<option value="$MODULE_DIR$/templates" />
|
| 20 |
+
</list>
|
| 21 |
+
</option>
|
| 22 |
+
</component>
|
| 23 |
+
</module>
|
.idea/vcs.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="VcsDirectoryMappings">
|
| 4 |
+
<mapping directory="" vcs="Git" />
|
| 5 |
+
</component>
|
| 6 |
+
</project>
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用官方 Python 镜像作为基础镜像
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
# 设置工作目录
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 将当前目录下的所有文件复制到工作目录
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
# 安装项目依赖
|
| 11 |
+
RUN pip install Flask python-dotenv huggingface_hub requests gunicorn flask-socketio python-engineio python-socketio eventlet
|
| 12 |
+
|
| 13 |
+
# 开放应用程序的端口
|
| 14 |
+
EXPOSE 5000
|
| 15 |
+
|
| 16 |
+
# 设置环境变量(可选,如果需要传递 Docker 环境中的环境变量)
|
| 17 |
+
# ENV USERNAME=your_username
|
| 18 |
+
# ENV PASSWORD=your_password
|
| 19 |
+
# ENV HF_TOKENS=token1,token2,token3
|
| 20 |
+
# ENV API_KEY=your_apikey
|
| 21 |
+
|
| 22 |
+
# 定义启动命令
|
| 23 |
+
CMD ["gunicorn", "--worker-class", "eventlet", "--bind", "0.0.0.0:5000", "app:app"]
|
api.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from functools import wraps
|
| 3 |
+
from huggingface_hub import HfApi
|
| 4 |
+
from config import API_KEY
|
| 5 |
+
|
| 6 |
+
api = Blueprint('api', __name__, url_prefix='/api/v1')
|
| 7 |
+
|
| 8 |
+
def require_api_key(f):
|
| 9 |
+
@wraps(f)
|
| 10 |
+
def decorated(*args, **kwargs):
|
| 11 |
+
auth_header = request.headers.get('Authorization')
|
| 12 |
+
if not auth_header:
|
| 13 |
+
return jsonify({'error': 'No Authorization header'}), 401
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
scheme, token = auth_header.split()
|
| 17 |
+
if scheme.lower() != 'bearer':
|
| 18 |
+
return jsonify({'error': 'Invalid authorization scheme'}), 401
|
| 19 |
+
if token != API_KEY:
|
| 20 |
+
return jsonify({'error': 'Invalid API key'}), 401
|
| 21 |
+
except ValueError:
|
| 22 |
+
return jsonify({'error': 'Invalid Authorization header format'}), 401
|
| 23 |
+
|
| 24 |
+
return f(*args, **kwargs)
|
| 25 |
+
return decorated
|
| 26 |
+
|
| 27 |
+
@api.route('/info/<token>', methods=['GET'])
|
| 28 |
+
@require_api_key
|
| 29 |
+
def list_spaces(token):
|
| 30 |
+
"""列出所有空间"""
|
| 31 |
+
try:
|
| 32 |
+
hf_api = HfApi(token=token)
|
| 33 |
+
# 验证token
|
| 34 |
+
try:
|
| 35 |
+
user_info = hf_api.whoami()
|
| 36 |
+
username = user_info["name"]
|
| 37 |
+
except Exception:
|
| 38 |
+
return jsonify({'error': 'Invalid HuggingFace token'}), 401
|
| 39 |
+
|
| 40 |
+
spaces = list(hf_api.list_spaces(author=username))
|
| 41 |
+
space_list = []
|
| 42 |
+
|
| 43 |
+
for space in spaces:
|
| 44 |
+
try:
|
| 45 |
+
space_info = hf_api.space_info(repo_id=space.id)
|
| 46 |
+
space_list.append(space_info.id)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
return jsonify({
|
| 51 |
+
'spaces': space_list,
|
| 52 |
+
'total': len(space_list)
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
return jsonify({'error': str(e)}), 500
|
| 57 |
+
|
| 58 |
+
@api.route('/info/<token>/<path:space_id>', methods=['GET'])
|
| 59 |
+
@require_api_key
|
| 60 |
+
def get_space_info(token, space_id):
|
| 61 |
+
"""获取特定空间信息"""
|
| 62 |
+
try:
|
| 63 |
+
hf_api = HfApi(token=token)
|
| 64 |
+
try:
|
| 65 |
+
space_info = hf_api.space_info(repo_id=space_id)
|
| 66 |
+
except Exception:
|
| 67 |
+
return jsonify({'error': 'Space not found'}), 404
|
| 68 |
+
|
| 69 |
+
# 获取运行状态
|
| 70 |
+
status = "未知状态"
|
| 71 |
+
if space_info.runtime:
|
| 72 |
+
status = space_info.runtime.stage if hasattr(space_info.runtime, 'stage') else "未知状态"
|
| 73 |
+
|
| 74 |
+
return jsonify({
|
| 75 |
+
'id': space_info.id,
|
| 76 |
+
'status': status,
|
| 77 |
+
'last_modified': space_info.lastModified.isoformat() if space_info.lastModified else None,
|
| 78 |
+
'created_at': space_info.created_at.isoformat() if space_info.created_at else None,
|
| 79 |
+
'sdk': space_info.sdk,
|
| 80 |
+
'tags': space_info.tags,
|
| 81 |
+
'private': space_info.private
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
return jsonify({'error': str(e)}), 500
|
| 86 |
+
|
| 87 |
+
@api.route('/action/<token>/<path:space_id>/restart', methods=['POST'])
|
| 88 |
+
@require_api_key
|
| 89 |
+
def restart_space(token, space_id):
|
| 90 |
+
"""重启空间"""
|
| 91 |
+
try:
|
| 92 |
+
hf_api = HfApi(token=token)
|
| 93 |
+
try:
|
| 94 |
+
hf_api.restart_space(repo_id=space_id)
|
| 95 |
+
return jsonify({
|
| 96 |
+
'success': True,
|
| 97 |
+
'message': f'Space {space_id} restart initiated successfully'
|
| 98 |
+
})
|
| 99 |
+
except Exception as e:
|
| 100 |
+
return jsonify({
|
| 101 |
+
'success': False,
|
| 102 |
+
'error': str(e)
|
| 103 |
+
}), 400
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
return jsonify({
|
| 107 |
+
'success': False,
|
| 108 |
+
'error': str(e)
|
| 109 |
+
}), 500
|
| 110 |
+
|
| 111 |
+
@api.route('/action/<token>/<path:space_id>/rebuild', methods=['POST'])
|
| 112 |
+
@require_api_key
|
| 113 |
+
def rebuild_space(token, space_id):
|
| 114 |
+
"""重建空间"""
|
| 115 |
+
try:
|
| 116 |
+
hf_api = HfApi(token=token)
|
| 117 |
+
try:
|
| 118 |
+
hf_api.restart_space(repo_id=space_id, factory_reboot=True)
|
| 119 |
+
return jsonify({
|
| 120 |
+
'success': True,
|
| 121 |
+
'message': f'Space {space_id} rebuild initiated successfully'
|
| 122 |
+
})
|
| 123 |
+
except Exception as e:
|
| 124 |
+
return jsonify({
|
| 125 |
+
'success': False,
|
| 126 |
+
'error': str(e)
|
| 127 |
+
}), 400
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
return jsonify({
|
| 131 |
+
'success': False,
|
| 132 |
+
'error': str(e)
|
| 133 |
+
}), 500
|
app.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
| 2 |
+
from flask_socketio import SocketIO, emit, disconnect
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from huggingface_hub import HfApi
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 8 |
+
from config import USERNAME, PASSWORD, HF_TOKENS
|
| 9 |
+
from api import api as api_blueprint
|
| 10 |
+
import concurrent.futures
|
| 11 |
+
import threading
|
| 12 |
+
import logging
|
| 13 |
+
import time
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
# 配置日志
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# 加载环境变量
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
app = Flask(__name__)
|
| 24 |
+
app.secret_key = os.urandom(24)
|
| 25 |
+
app.register_blueprint(api_blueprint)
|
| 26 |
+
socketio = SocketIO(app)
|
| 27 |
+
|
| 28 |
+
# 缓存管理
|
| 29 |
+
class SpaceCache:
|
| 30 |
+
def __init__(self):
|
| 31 |
+
self.spaces = {}
|
| 32 |
+
self.last_update = None
|
| 33 |
+
self.lock = threading.Lock()
|
| 34 |
+
self.active_clients = set() # 跟踪活动的客户端
|
| 35 |
+
|
| 36 |
+
def update_all(self, spaces_data):
|
| 37 |
+
with self.lock:
|
| 38 |
+
self.spaces = {space['repo_id']: space for space in spaces_data}
|
| 39 |
+
self.last_update = datetime.now()
|
| 40 |
+
|
| 41 |
+
def get_all(self):
|
| 42 |
+
with self.lock:
|
| 43 |
+
return list(self.spaces.values()) if self.spaces else []
|
| 44 |
+
|
| 45 |
+
def is_expired(self, expire_minutes=5):
|
| 46 |
+
if not self.last_update:
|
| 47 |
+
return True
|
| 48 |
+
return datetime.now() - self.last_update > timedelta(minutes=expire_minutes)
|
| 49 |
+
|
| 50 |
+
def add_client(self, client_id):
|
| 51 |
+
with self.lock:
|
| 52 |
+
self.active_clients.add(client_id)
|
| 53 |
+
|
| 54 |
+
def remove_client(self, client_id):
|
| 55 |
+
with self.lock:
|
| 56 |
+
self.active_clients.discard(client_id)
|
| 57 |
+
|
| 58 |
+
def has_active_clients(self):
|
| 59 |
+
with self.lock:
|
| 60 |
+
return len(self.active_clients) > 0
|
| 61 |
+
|
| 62 |
+
space_cache = SpaceCache()
|
| 63 |
+
|
| 64 |
+
# WebSocket 事件处理
|
| 65 |
+
@socketio.on('connect')
|
| 66 |
+
def handle_connect():
|
| 67 |
+
if 'authenticated' not in session or not session['authenticated']:
|
| 68 |
+
disconnect()
|
| 69 |
+
return
|
| 70 |
+
|
| 71 |
+
client_id = request.sid
|
| 72 |
+
space_cache.add_client(client_id)
|
| 73 |
+
logger.info(f"Client connected: {client_id}")
|
| 74 |
+
|
| 75 |
+
@socketio.on('disconnect')
|
| 76 |
+
def handle_disconnect():
|
| 77 |
+
client_id = request.sid
|
| 78 |
+
space_cache.remove_client(client_id)
|
| 79 |
+
logger.info(f"Client disconnected: {client_id}")
|
| 80 |
+
|
| 81 |
+
# 登录验证装饰器
|
| 82 |
+
def login_required(f):
|
| 83 |
+
@wraps(f)
|
| 84 |
+
def decorated_function(*args, **kwargs):
|
| 85 |
+
if 'authenticated' not in session or not session['authenticated']:
|
| 86 |
+
return redirect(url_for('login'))
|
| 87 |
+
return f(*args, **kwargs)
|
| 88 |
+
return decorated_function
|
| 89 |
+
|
| 90 |
+
def process_single_space(space, hf_api, username, token):
|
| 91 |
+
try:
|
| 92 |
+
space_info = hf_api.space_info(repo_id=space.id)
|
| 93 |
+
space_runtime = space_info.runtime
|
| 94 |
+
|
| 95 |
+
status = "未知状态"
|
| 96 |
+
if space_runtime:
|
| 97 |
+
status = space_runtime.stage if hasattr(space_runtime, 'stage') else "未知状态"
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"repo_id": space_info.id,
|
| 101 |
+
"name": space_info.cardData.get('title') or space_info.id.split('/')[-1],
|
| 102 |
+
"owner": space_info.author,
|
| 103 |
+
"username": username,
|
| 104 |
+
"token": token,
|
| 105 |
+
"url": f"https://{space_info.author}-{space_info.id.split('/')[-1]}.hf.space",
|
| 106 |
+
"status": status,
|
| 107 |
+
"last_modified": space_info.lastModified.strftime("%Y-%m-%d %H:%M:%S") if space_info.lastModified else "未知",
|
| 108 |
+
"created_at": space_info.created_at.strftime("%Y-%m-%d %H:%M:%S") if space_info.created_at else "未知",
|
| 109 |
+
"sdk": space_info.sdk,
|
| 110 |
+
"tags": space_info.tags,
|
| 111 |
+
"private": space_info.private,
|
| 112 |
+
"app_port": space_info.cardData.get('app_port', '未知')
|
| 113 |
+
}
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error(f"处理 Space {space.id} 时出错: {e}")
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
def get_all_user_spaces():
|
| 119 |
+
# 检查缓存是否有效
|
| 120 |
+
if not space_cache.is_expired():
|
| 121 |
+
logger.info("从缓存获取 Spaces 数据")
|
| 122 |
+
return space_cache.get_all()
|
| 123 |
+
|
| 124 |
+
all_spaces = []
|
| 125 |
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
| 126 |
+
for token in HF_TOKENS:
|
| 127 |
+
try:
|
| 128 |
+
hf_api = HfApi(token=token)
|
| 129 |
+
user_info = hf_api.whoami()
|
| 130 |
+
username = user_info["name"]
|
| 131 |
+
logger.info(f"获取到用户信息: {username}")
|
| 132 |
+
|
| 133 |
+
spaces = list(hf_api.list_spaces(author=username))
|
| 134 |
+
logger.info(f"获取到 {len(spaces)} 个 Spaces")
|
| 135 |
+
|
| 136 |
+
# 并行处理每个space
|
| 137 |
+
future_to_space = {
|
| 138 |
+
executor.submit(process_single_space, space, hf_api, username, token): space
|
| 139 |
+
for space in spaces
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
for future in concurrent.futures.as_completed(future_to_space):
|
| 143 |
+
space_data = future.result()
|
| 144 |
+
if space_data:
|
| 145 |
+
all_spaces.append(space_data)
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"获取 Spaces 列表失败 (token: {token[:5]}...): {e}")
|
| 149 |
+
import traceback
|
| 150 |
+
traceback.print_exc()
|
| 151 |
+
|
| 152 |
+
# 按名称排序
|
| 153 |
+
all_spaces.sort(key=lambda x: x['name'].lower())
|
| 154 |
+
|
| 155 |
+
# 更新缓存
|
| 156 |
+
space_cache.update_all(all_spaces)
|
| 157 |
+
|
| 158 |
+
logger.info(f"总共获取到 {len(all_spaces)} 个 Spaces")
|
| 159 |
+
return all_spaces
|
| 160 |
+
|
| 161 |
+
# 后台更新缓存的函数
|
| 162 |
+
def update_cache_if_needed():
|
| 163 |
+
"""在有活动客户端时更新缓存"""
|
| 164 |
+
while True:
|
| 165 |
+
try:
|
| 166 |
+
if space_cache.has_active_clients() and space_cache.is_expired():
|
| 167 |
+
logger.info("Updating cache due to active clients")
|
| 168 |
+
spaces = get_all_user_spaces()
|
| 169 |
+
space_cache.update_all(spaces)
|
| 170 |
+
socketio.emit('spaces_updated', {'timestamp': time.time()})
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"Cache update failed: {e}")
|
| 173 |
+
time.sleep(60) # 每分钟检查一次
|
| 174 |
+
|
| 175 |
+
# 启动缓存更新线程
|
| 176 |
+
update_thread = threading.Thread(target=update_cache_if_needed, daemon=True)
|
| 177 |
+
update_thread.start()
|
| 178 |
+
|
| 179 |
+
@app.route("/", methods=["GET", "POST"])
|
| 180 |
+
def login():
|
| 181 |
+
if 'authenticated' in session and session['authenticated']:
|
| 182 |
+
return redirect(url_for('dashboard'))
|
| 183 |
+
|
| 184 |
+
if request.method == "POST":
|
| 185 |
+
username = request.form.get("username")
|
| 186 |
+
password = request.form.get("password")
|
| 187 |
+
if username == USERNAME and password == PASSWORD:
|
| 188 |
+
session['authenticated'] = True
|
| 189 |
+
return redirect(url_for("dashboard"))
|
| 190 |
+
else:
|
| 191 |
+
return render_template("index.html", error="用户名或密码错误")
|
| 192 |
+
return render_template("index.html", error=None)
|
| 193 |
+
|
| 194 |
+
@app.route("/logout")
|
| 195 |
+
def logout():
|
| 196 |
+
session.clear()
|
| 197 |
+
return redirect(url_for('login'))
|
| 198 |
+
|
| 199 |
+
@app.route("/dashboard")
|
| 200 |
+
@login_required
|
| 201 |
+
def dashboard():
|
| 202 |
+
spaces = get_all_user_spaces()
|
| 203 |
+
logger.info(f"Dashboard 显示 {len(spaces)} 个 Spaces")
|
| 204 |
+
return render_template("dashboard.html", spaces=spaces)
|
| 205 |
+
|
| 206 |
+
@app.route("/api/space/<path:repo_id>/status")
|
| 207 |
+
@login_required
|
| 208 |
+
def get_space_status(repo_id):
|
| 209 |
+
spaces = get_all_user_spaces()
|
| 210 |
+
space = next((s for s in spaces if s["repo_id"] == repo_id), None)
|
| 211 |
+
if not space:
|
| 212 |
+
return jsonify({"error": "Space not found"}), 404
|
| 213 |
+
return jsonify({
|
| 214 |
+
"id": repo_id,
|
| 215 |
+
"status": space["status"]
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
def restart_space(repo_id, token):
|
| 219 |
+
try:
|
| 220 |
+
hf_api = HfApi(token=token)
|
| 221 |
+
hf_api.restart_space(repo_id=repo_id)
|
| 222 |
+
return f"成功重启 Space: {repo_id}"
|
| 223 |
+
except Exception as e:
|
| 224 |
+
return f"重启 Space {repo_id} 失败: {e}"
|
| 225 |
+
|
| 226 |
+
def rebuild_space(repo_id, token):
|
| 227 |
+
try:
|
| 228 |
+
hf_api = HfApi(token=token)
|
| 229 |
+
hf_api.restart_space(repo_id=repo_id, factory_reboot=True)
|
| 230 |
+
return f"成功重建 Space: {repo_id}"
|
| 231 |
+
except Exception as e:
|
| 232 |
+
return f"重建 Space {repo_id} 失败: {e}"
|
| 233 |
+
|
| 234 |
+
@app.route("/action/<action_type>/<path:repo_id>")
|
| 235 |
+
@login_required
|
| 236 |
+
def space_action(action_type, repo_id):
|
| 237 |
+
spaces = get_all_user_spaces()
|
| 238 |
+
space = next((s for s in spaces if s["repo_id"] == repo_id), None)
|
| 239 |
+
|
| 240 |
+
if not space:
|
| 241 |
+
return "Space not found", 404
|
| 242 |
+
|
| 243 |
+
if action_type == "restart":
|
| 244 |
+
message = restart_space(repo_id, space["token"])
|
| 245 |
+
elif action_type == "rebuild":
|
| 246 |
+
message = rebuild_space(repo_id, space["token"])
|
| 247 |
+
else:
|
| 248 |
+
message = "未知操作"
|
| 249 |
+
return render_template("action_result.html", message=message)
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
socketio.run(app, host='0.0.0.0', port=5000)
|
config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
USERNAME = os.getenv("USERNAME")
|
| 7 |
+
PASSWORD = os.getenv("PASSWORD")
|
| 8 |
+
HF_TOKENS = os.getenv("HF_TOKENS", "").split(",")
|
| 9 |
+
API_KEY = os.getenv('API_KEY')
|
| 10 |
+
|
| 11 |
+
if not USERNAME or not PASSWORD or not HF_TOKENS or not API_KEY:
|
| 12 |
+
raise Exception("请在 .env 文件中配置 USERNAME、PASSWORD、HF_TOKENS 和 API_KEY")
|
templates/action_result.html
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>操作结果 - HF Space Manager</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
background-color: #f5f5f7;
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
display: flex;
|
| 19 |
+
flex-direction: column;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.container {
|
| 23 |
+
flex: 1;
|
| 24 |
+
display: flex;
|
| 25 |
+
flex-direction: column;
|
| 26 |
+
justify-content: center;
|
| 27 |
+
align-items: center;
|
| 28 |
+
padding: 2rem;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.result-card {
|
| 32 |
+
background: white;
|
| 33 |
+
padding: 2rem;
|
| 34 |
+
border-radius: 18px;
|
| 35 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
| 36 |
+
text-align: center;
|
| 37 |
+
max-width: 600px;
|
| 38 |
+
width: 100%;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.message {
|
| 42 |
+
color: #1d1d1f;
|
| 43 |
+
margin-bottom: 2rem;
|
| 44 |
+
font-size: 1.1rem;
|
| 45 |
+
line-height: 1.5;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.back-button {
|
| 49 |
+
display: inline-block;
|
| 50 |
+
padding: 0.75rem 1.5rem;
|
| 51 |
+
background: #0071e3;
|
| 52 |
+
color: white;
|
| 53 |
+
text-decoration: none;
|
| 54 |
+
border-radius: 8px;
|
| 55 |
+
transition: all 0.3s ease;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.back-button:hover {
|
| 59 |
+
background: #0077ED;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.footer {
|
| 63 |
+
text-align: center;
|
| 64 |
+
padding: 2rem;
|
| 65 |
+
color: #86868b;
|
| 66 |
+
font-size: 0.9rem;
|
| 67 |
+
background: white;
|
| 68 |
+
border-top: 1px solid #e5e5e7;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.footer a {
|
| 72 |
+
color: #0071e3;
|
| 73 |
+
text-decoration: none;
|
| 74 |
+
transition: color 0.3s ease;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.footer a:hover {
|
| 78 |
+
color: #0077ED;
|
| 79 |
+
text-decoration: underline;
|
| 80 |
+
}
|
| 81 |
+
</style>
|
| 82 |
+
</head>
|
| 83 |
+
<body>
|
| 84 |
+
<div class="container">
|
| 85 |
+
<div class="result-card">
|
| 86 |
+
<div class="message">{{ message }}</div>
|
| 87 |
+
<a href="/dashboard" class="back-button">返回控制面板</a>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<footer class="footer">
|
| 92 |
+
<a href="https://github.com/ssfun/hf-space-manager">HF Space Manager</a> 由
|
| 93 |
+
<a href="https://github.com/ssfun">ssfun</a> 构建,源代码遵循
|
| 94 |
+
<a href="https://opensource.org/license/mit">MIT 协议</a>
|
| 95 |
+
</footer>
|
| 96 |
+
</body>
|
| 97 |
+
</html>
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>HF Space Manager -控制面板</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
background-color: #f5f5f7;
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
display: flex;
|
| 19 |
+
flex-direction: column;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.header {
|
| 23 |
+
background: rgba(255, 255, 255, 0.85);
|
| 24 |
+
backdrop-filter: blur(20px);
|
| 25 |
+
position: fixed;
|
| 26 |
+
top: 0;
|
| 27 |
+
left: 0;
|
| 28 |
+
right: 0;
|
| 29 |
+
padding: 1rem 2rem;
|
| 30 |
+
display: flex;
|
| 31 |
+
justify-content: space-between;
|
| 32 |
+
align-items: center;
|
| 33 |
+
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);z-index: 1000;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.header h1 {
|
| 37 |
+
font-size: 1.5rem;
|
| 38 |
+
color: #1d1d1f;
|
| 39 |
+
font-weight: 600;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.logout {
|
| 43 |
+
background: none;
|
| 44 |
+
border: none;
|
| 45 |
+
color: #0071e3;
|
| 46 |
+
font-size: 1rem;
|
| 47 |
+
cursor: pointer;
|
| 48 |
+
padding: 8px 16px;
|
| 49 |
+
border-radius: 8px;
|
| 50 |
+
transition: all 0.3s ease;text-decoration: none;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.logout:hover {
|
| 54 |
+
background: rgba(0, 113, 227, 0.1);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.container {
|
| 58 |
+
flex: 1;
|
| 59 |
+
width: 100%;
|
| 60 |
+
max-width: 1200px;
|
| 61 |
+
margin: 100px auto 0;
|
| 62 |
+
padding: 0 2rem 2rem;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.loading-overlay {
|
| 66 |
+
position: fixed;
|
| 67 |
+
top: 0;
|
| 68 |
+
left: 0;
|
| 69 |
+
right: 0;
|
| 70 |
+
bottom: 0;
|
| 71 |
+
background: rgba(255, 255, 255, 0.8);
|
| 72 |
+
display: flex;
|
| 73 |
+
justify-content: center;
|
| 74 |
+
align-items: center;
|
| 75 |
+
z-index: 9999;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.loading-spinner {
|
| 79 |
+
width: 50px;
|
| 80 |
+
height: 50px;
|
| 81 |
+
border: 5px solid #f3f3f3;
|
| 82 |
+
border-top: 5px solid #0071e3;
|
| 83 |
+
border-radius: 50%;
|
| 84 |
+
animation: spin 1s linear infinite;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
@keyframes spin {
|
| 88 |
+
0% { transform: rotate(0deg); }
|
| 89 |
+
100% { transform: rotate(360deg); }
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.owner-section {
|
| 93 |
+
margin-bottom: 2rem;background: white;
|
| 94 |
+
border-radius: 18px;
|
| 95 |
+
padding: 1.5rem;
|
| 96 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.owner-name {
|
| 100 |
+
font-size: 1.3rem;
|
| 101 |
+
font-weight: 600;
|
| 102 |
+
color: #1d1d1f;
|
| 103 |
+
margin-bottom: 1rem;
|
| 104 |
+
padding-bottom: 0.5rem;
|
| 105 |
+
border-bottom: 1px solid #e5e5e7;
|
| 106 |
+
display: flex;
|
| 107 |
+
justify-content: space-between;align-items: center;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.space-count {
|
| 111 |
+
font-size: 0.9rem;
|
| 112 |
+
color: #86868b;}
|
| 113 |
+
|
| 114 |
+
.space-status-count {
|
| 115 |
+
margin-left: 10px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.space-grid {
|
| 119 |
+
display: grid;
|
| 120 |
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
| 121 |
+
gap: 1.5rem;
|
| 122 |
+
width: 100%;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.space-card {
|
| 126 |
+
background: white;
|
| 127 |
+
border-radius: 12px;
|
| 128 |
+
padding: 1.5rem;
|
| 129 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
| 130 |
+
transition: all 0.3s ease;
|
| 131 |
+
display: flex;
|
| 132 |
+
flex-direction: column;}
|
| 133 |
+
|
| 134 |
+
.space-card:hover {
|
| 135 |
+
transform: translateY(-3px);
|
| 136 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.space-name {
|
| 140 |
+
font-size: 1.1rem;
|
| 141 |
+
font-weight: 500;
|
| 142 |
+
color: #1d1d1f;
|
| 143 |
+
margin-bottom: 1rem;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.space-info {
|
| 147 |
+
font-size: 0.9rem;
|
| 148 |
+
color: #86868b;
|
| 149 |
+
margin-bottom: 1rem;
|
| 150 |
+
flex-grow: 1;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.space-info p {
|
| 154 |
+
margin-bottom: 0.5rem;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.status-badge {
|
| 158 |
+
display: inline-block;
|
| 159 |
+
padding: 2px 8px;
|
| 160 |
+
border-radius: 4px;
|
| 161 |
+
font-weight: 500;}
|
| 162 |
+
|
| 163 |
+
.status-BUILDING {
|
| 164 |
+
background-color: #ff9500;
|
| 165 |
+
color: white;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.status-RUNNING {
|
| 169 |
+
background-color: #34c759;
|
| 170 |
+
color: white;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.status-SLEEPING {
|
| 174 |
+
background-color: #007aff;
|
| 175 |
+
color: white;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.status-STOPPED {
|
| 179 |
+
background-color: #8e8e93;
|
| 180 |
+
color: white;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.status-FAILED {
|
| 184 |
+
background-color: #ff3b30;
|
| 185 |
+
color: white;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.status-BUILD_ERROR {
|
| 189 |
+
background-color: #ff3b30;
|
| 190 |
+
color: white;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.status-UNKNOWN {
|
| 194 |
+
background-color: #8e8e93;
|
| 195 |
+
color: white;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.action-buttons {
|
| 199 |
+
display: grid;
|
| 200 |
+
grid-template-columns: repeat(3, 1fr);
|
| 201 |
+
gap: 0.5rem;
|
| 202 |
+
margin-top: auto;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.action-button {
|
| 206 |
+
padding: 8px 12px;
|
| 207 |
+
border-radius: 8px;
|
| 208 |
+
border: none;
|
| 209 |
+
font-size: 0.9rem;
|
| 210 |
+
cursor: pointer;
|
| 211 |
+
transition: all 0.3s ease;text-align: center;
|
| 212 |
+
text-decoration: none;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.view {
|
| 216 |
+
background: #e5e5e7;
|
| 217 |
+
color: #1d1d1f;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.view:hover {
|
| 221 |
+
background: #d5d5d7;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.restart {
|
| 225 |
+
background: #0071e3;
|
| 226 |
+
color: white;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.restart:hover {
|
| 230 |
+
background: #0077ED;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.rebuild {
|
| 234 |
+
background: #f5f5f7;
|
| 235 |
+
color: #1d1d1f;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.rebuild:hover {
|
| 239 |
+
background: #e5e5e7;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.footer {
|
| 243 |
+
text-align: center;
|
| 244 |
+
padding: 2rem;
|
| 245 |
+
color: #86868b;
|
| 246 |
+
font-size: 0.9rem;
|
| 247 |
+
background: white;
|
| 248 |
+
border-top: 1px solid #e5e5e7;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.footer a {
|
| 252 |
+
color: #0071e3;
|
| 253 |
+
text-decoration: none;transition: color 0.3s ease;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.footer a:hover {
|
| 257 |
+
color: #0077ED;
|
| 258 |
+
text-decoration: underline;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
@media (max-width: 768px) {
|
| 262 |
+
.container {
|
| 263 |
+
padding: 0 1rem;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.owner-name {
|
| 267 |
+
flex-direction: column;
|
| 268 |
+
align-items: flex-start;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.space-count {
|
| 272 |
+
margin-top: 0.5rem;
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
</style>
|
| 276 |
+
</head>
|
| 277 |
+
<body>
|
| 278 |
+
<div id="loading" class="loading-overlay">
|
| 279 |
+
<div class="loading-spinner"></div>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<header class="header">
|
| 283 |
+
<h1>HF Space Manager</h1>
|
| 284 |
+
<a href="/logout" class="logout">退出登录</a>
|
| 285 |
+
</header>
|
| 286 |
+
|
| 287 |
+
<div class="container">
|
| 288 |
+
{% if spaces %}
|
| 289 |
+
{% set grouped_spaces = {} %}
|
| 290 |
+
{% for space in spaces %}
|
| 291 |
+
{% if space.owner not in grouped_spaces %}
|
| 292 |
+
{% set _ = grouped_spaces.update({space.owner: []}) %}
|
| 293 |
+
{% endif %}
|
| 294 |
+
{% set _ = grouped_spaces[space.owner].append(space) %}
|
| 295 |
+
{% endfor %}
|
| 296 |
+
|
| 297 |
+
{% for owner, owner_spaces in grouped_spaces.items() %}
|
| 298 |
+
{% set sorted_spaces = owner_spaces %}
|
| 299 |
+
{% set running_count = sorted_spaces | selectattr('status','equalto','RUNNING') | list | length %}
|
| 300 |
+
{% set building_count = sorted_spaces | selectattr('status','equalto','BUILDING') | list | length %}
|
| 301 |
+
{% set sleeping_count = sorted_spaces | selectattr('status','equalto','SLEEPING') | list | length %}
|
| 302 |
+
{% set stopped_count = sorted_spaces | selectattr('status','equalto','STOPPED') | list | length %}
|
| 303 |
+
{% set failed_count = sorted_spaces | selectattr('status','equalto','BUILD_ERROR') | list | length %}
|
| 304 |
+
<div class="owner-section">
|
| 305 |
+
<div class="owner-name">
|
| 306 |
+
<span>{{ owner }}</span>
|
| 307 |
+
<span class="space-count">
|
| 308 |
+
总数: {{ sorted_spaces | length }}
|
| 309 |
+
<span class="space-status-count">运行:{{ running_count }}</span>
|
| 310 |
+
<span class="space-status-count">休眠:{{ sleeping_count }}</span>
|
| 311 |
+
<span class="space-status-count">停止:{{ stopped_count }}</span>
|
| 312 |
+
<span class="space-status-count">失败:{{ failed_count }}</span>
|
| 313 |
+
</span>
|
| 314 |
+
</div><div class="space-grid">
|
| 315 |
+
{% for space in sorted_spaces %}
|
| 316 |
+
<div class="space-card" data-space-id="{{ space.repo_id }}"><div class="space-name">{{ space.name }}</div>
|
| 317 |
+
<div class="space-info">
|
| 318 |
+
<p>ID: {{ space.repo_id }}</p>
|
| 319 |
+
<p>状态: <span class="status-badge status-{{ space.status }}">{{ space.status }}</span></p><p>创建时间: {{ space.created_at }}</p>
|
| 320 |
+
<p>最后修改: {{ space.last_modified }}</p>
|
| 321 |
+
<p>SDK: {{ space.sdk }}</p><p>App端口: {{ space.app_port }}</p>
|
| 322 |
+
{% if space.tags %}
|
| 323 |
+
<p>标签:{% for tag in space.tags %}<span class="tag">{{ tag }}</span>
|
| 324 |
+
{% endfor %}
|
| 325 |
+
</p>
|
| 326 |
+
{% endif %}<p>私有: {{ '是' if space.private else '否' }}</p>
|
| 327 |
+
</div><div class="action-buttons">
|
| 328 |
+
<a href="{{ space.url }}" target="_blank" class="action-button view">查看</a>
|
| 329 |
+
<button onclick="confirmAction('restart', '{{ space.repo_id }}')" class="action-button restart">重启</button>
|
| 330 |
+
<button onclick="confirmAction('rebuild', '{{ space.repo_id }}')" class="action-button rebuild">重建</button>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
{% endfor %}
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
{% endfor %}
|
| 337 |
+
{% else %}
|
| 338 |
+
<div class="owner-section">
|
| 339 |
+
<p style="text-align: center; color: #86868b;">没有找到任何 Spaces。请确保你的账户中有创建的Spaces,并且提供的 token 有正确的权限。</p>
|
| 340 |
+
</div>
|
| 341 |
+
{% endif %}
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
<footer class="footer">
|
| 345 |
+
<a href="https://github.com/ssfun/hf-space-manager">HF Space Manager</a> 由
|
| 346 |
+
<a href="https://github.com/ssfun">ssfun</a> 构建,源代码遵循
|
| 347 |
+
<a href="https://opensource.org/license/mit">MIT 协议</a>
|
| 348 |
+
</footer>
|
| 349 |
+
|
| 350 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
| 351 |
+
<script>
|
| 352 |
+
// 连接 WebSocket
|
| 353 |
+
const socket = io();
|
| 354 |
+
|
| 355 |
+
socket.on('connect', () => {
|
| 356 |
+
console.log('Connected to server');
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
socket.on('disconnect', () => {
|
| 360 |
+
console.log('Disconnected from server');
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
// 当收到缓存更新通知时刷新数据
|
| 364 |
+
socket.on('spaces_updated', (data) => {
|
| 365 |
+
updateSpaceStatuses();
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
+
// 页面可见性变化处理
|
| 369 |
+
document.addEventListener('visibilitychange', function() {
|
| 370 |
+
if (document.hidden) {
|
| 371 |
+
// 页面隐藏时断开连接
|
| 372 |
+
socket.disconnect();
|
| 373 |
+
} else {
|
| 374 |
+
// 页面可见时重新连接
|
| 375 |
+
socket.connect();
|
| 376 |
+
}
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
// 页面加载完成后隐藏加载动画
|
| 380 |
+
window.addEventListener('load', function() {
|
| 381 |
+
document.getElementById('loading').style.display = 'none';
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
// 定期更新状态
|
| 385 |
+
function updateSpaceStatuses() {document.querySelectorAll('.space-card').forEach(card => {
|
| 386 |
+
const spaceId = card.dataset.spaceId;
|
| 387 |
+
fetch(`/api/space/${spaceId}/status`)
|
| 388 |
+
.then(response => response.json())
|
| 389 |
+
.then(data => {
|
| 390 |
+
const statusElement = card.querySelector('.status-badge');
|
| 391 |
+
if (statusElement && data.status) {
|
| 392 |
+
statusElement.className = `status-badge status-${data.status}`;statusElement.textContent = data.status;
|
| 393 |
+
}
|
| 394 |
+
})
|
| 395 |
+
.catch(error => console.error('Error updating status:', error));
|
| 396 |
+
});
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// 在进行操作时显示加载动画
|
| 400 |
+
function confirmAction(action, spaceId) {
|
| 401 |
+
var actionText = action === 'restart' ? '重启' : '重建';
|
| 402 |
+
if (confirm(`确定要${actionText} "${spaceId}" 吗?`)) {document.getElementById('loading').style.display = 'flex';
|
| 403 |
+
window.location.href = `/action/${action}/${spaceId}`;
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// 每30秒更新一次状态
|
| 408 |
+
setInterval(updateSpaceStatuses, 30000);
|
| 409 |
+
</script>
|
| 410 |
+
</body>
|
| 411 |
+
</html>
|
templates/index.html
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>登录 - HF Space Manager</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
background-color: #f5f5f7;
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
display: flex;
|
| 19 |
+
flex-direction: column;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.login-container {
|
| 23 |
+
flex: 1;
|
| 24 |
+
display: flex;
|
| 25 |
+
flex-direction: column;
|
| 26 |
+
justify-content: center;
|
| 27 |
+
align-items: center;
|
| 28 |
+
padding: 2rem;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.logo {
|
| 32 |
+
font-size: 2rem;
|
| 33 |
+
font-weight: 600;
|
| 34 |
+
color: #1d1d1f;
|
| 35 |
+
margin-bottom: 2rem;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
form {
|
| 39 |
+
background: white;
|
| 40 |
+
padding: 2rem;
|
| 41 |
+
border-radius: 18px;
|
| 42 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
| 43 |
+
width: 100%;
|
| 44 |
+
max-width: 400px;}
|
| 45 |
+
|
| 46 |
+
.form-group {
|
| 47 |
+
margin-bottom: 1.5rem;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
label {
|
| 51 |
+
display: block;
|
| 52 |
+
margin-bottom: 0.5rem;
|
| 53 |
+
color: #1d1d1f;
|
| 54 |
+
font-weight: 500;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
input {
|
| 58 |
+
width: 100%;
|
| 59 |
+
padding: 0.75rem;
|
| 60 |
+
border: 1px solid #e5e5e7;
|
| 61 |
+
border-radius: 8px;
|
| 62 |
+
font-size: 1rem;
|
| 63 |
+
transition: all 0.3s ease;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
input:focus {
|
| 67 |
+
outline: none;
|
| 68 |
+
border-color: #0071e3;
|
| 69 |
+
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.1);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
button {
|
| 73 |
+
width: 100%;
|
| 74 |
+
padding: 0.75rem;
|
| 75 |
+
background: #0071e3;
|
| 76 |
+
color: white;
|
| 77 |
+
border: none;
|
| 78 |
+
border-radius: 8px;
|
| 79 |
+
font-size: 1rem;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
transition: all 0.3s ease;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
button:hover {
|
| 85 |
+
background: #0077ED;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.error-message {
|
| 89 |
+
color: #ff3b30;
|
| 90 |
+
margin-bottom: 1rem;text-align: center;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.footer {
|
| 94 |
+
text-align: center;
|
| 95 |
+
padding: 2rem;
|
| 96 |
+
color: #86868b;font-size: 0.9rem;
|
| 97 |
+
background: white;
|
| 98 |
+
border-top: 1px solid #e5e5e7;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.footer a {
|
| 102 |
+
color: #0071e3;
|
| 103 |
+
text-decoration: none;transition: color 0.3s ease;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.footer a:hover {
|
| 107 |
+
color: #0077ED;text-decoration: underline;
|
| 108 |
+
}
|
| 109 |
+
</style>
|
| 110 |
+
</head>
|
| 111 |
+
<body>
|
| 112 |
+
<div class="login-container">
|
| 113 |
+
<div class="logo">HF Space Manager</div>
|
| 114 |
+
<form method="post">
|
| 115 |
+
<div class="form-group">
|
| 116 |
+
<label for="username">用户名</label>
|
| 117 |
+
<input type="text" id="username" name="username" required>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="form-group">
|
| 120 |
+
<label for="password">密码</label>
|
| 121 |
+
<input type="password" id="password" name="password" required>
|
| 122 |
+
</div>
|
| 123 |
+
{% if error %}
|
| 124 |
+
<div class="error-message">{{ error }}</div>
|
| 125 |
+
{% endif %}
|
| 126 |
+
<button type="submit">登录</button>
|
| 127 |
+
</form>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<footer class="footer">
|
| 131 |
+
<a href="https://github.com/ssfun/hf-space-manager">HF Space Manager</a> 由<a href="https://github.com/ssfun">ssfun</a> 构建,源代码遵循
|
| 132 |
+
<a href="https://opensource.org/license/mit">MIT 协议</a>
|
| 133 |
+
</footer>
|
| 134 |
+
</body>
|
| 135 |
+
</html>
|