refactor: 数据源菜单合一+布局优化

- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」
- InstancePage 改为统一入口,顶部Tab切换类型过滤
- 添加数据源改为弹框Modal(默认MySQL)
- 去掉左下角用户区,退出登录移到右上角header
- 侧边栏收窄220px,整体布局更紧凑
- 后端202 tests passing
- 前端构建零错误
This commit is contained in:
logan committed 2026-05-22 22:53:48 +08:00
1 parent bab55d3927
commit 5cbd0e3441
23 files changed
+2881 -94

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
"""Tests for Redis management API endpoints — all methods mocked."""
import pytest
from unittest.mock import AsyncMock, patch
from tests.conftest import auth_header
# ── Helpers ──────────────────────────────────────────────────────
REDIS_INSTANCE_DATA = {
"name": "Test Redis",
"db_type": "redis",
"host": "127.0.0.1",
"port": 6379,
"username": "default",
"password": "test123",
"database": "0",
"env": "development",
"description": "Test Redis instance",
}
MOCK_INFO = {
"redis_version": "7.2.3",
"uptime_in_days": 30,
"connected_clients": 42,
"db0": {"keys": 1000, "expires": 500},
"used_memory_human": "256M",
"keyspace_hits": 10000,
"keyspace_misses": 100,
}
async def _create_redis_instance(client, admin_token):
"""Create a Redis instance and return its ID."""
resp = await client.post(
"/api/v1/instances",
json=REDIS_INSTANCE_DATA,
headers=auth_header(admin_token),
)
assert resp.status_code == 201
return resp.json()["id"]
# ── Info endpoint ────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_info(mock_cls, client, admin_token):
mock_cls.get_info = AsyncMock(return_value=MOCK_INFO)
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/info",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["redis_version"] == "7.2.3"
# ── DBSIZE endpoint ──────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_dbsize(mock_cls, client, admin_token):
mock_cls.get_dbsize = AsyncMock(return_value=1000)
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/dbsize?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["size"] == 1000
# ── Keys list ────────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_keys(mock_cls, client, admin_token):
mock_keys = [
{"key": "user:1", "type": "string", "ttl": 3600},
{"key": "session:abc", "type": "hash", "ttl": -1},
]
mock_cls.scan_keys = AsyncMock(return_value=(0, mock_keys))
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/keys?db=0&pattern=*&count=50",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data["keys"]) == 2
assert data["keys"][0]["key"] == "user:1"
# ── Key detail ───────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_key_detail(mock_cls, client, admin_token):
mock_cls.get_key_value = AsyncMock(return_value={
"key": "user:1", "type": "string", "value": "hello", "ttl": 3600,
})
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/keys/user:1?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["value"] == "hello"
# ── Key CRUD ─────────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_create_redis_key(mock_cls, client, admin_token):
mock_cls.set_key_value = AsyncMock(return_value="OK")
iid = await _create_redis_instance(client, admin_token)
resp = await client.post(
"/api/v1/redis/" + str(iid) + "/keys",
json={"db": 0, "key": "test_key", "value": "test_value", "type": "string", "ttl": 60},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["message"] == "操作成功"
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_delete_redis_key(mock_cls, client, admin_token):
mock_cls.del_key = AsyncMock(return_value=1)
iid = await _create_redis_instance(client, admin_token)
resp = await client.delete(
"/api/v1/redis/" + str(iid) + "/keys/test_key?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["deleted"] == 1
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_set_expire(mock_cls, client, admin_token):
mock_cls.set_ttl = AsyncMock(return_value=True)
iid = await _create_redis_instance(client, admin_token)
resp = await client.put(
"/api/v1/redis/" + str(iid) + "/keys/test_key/expire",
json={"db": 0, "seconds": 300},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# ── Command execution ────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_execute_command(mock_cls, client, admin_token):
mock_cls.execute_command = AsyncMock(return_value="PONG")
iid = await _create_redis_instance(client, admin_token)
resp = await client.post(
"/api/v1/redis/" + str(iid) + "/command",
json={"db": 0, "command": "PING"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["output"] == "PONG"
# ── Slowlog & Clients ────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_slowlog(mock_cls, client, admin_token):
mock_cls.get_slowlog = AsyncMock(return_value=[
{"id": 1, "timestamp": 1700000000, "duration_us": 5000, "command": "KEYS *"},
])
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/slowlog?count=10",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert len(resp.json()) == 1
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_clients(mock_cls, client, admin_token):
mock_cls.get_clients = AsyncMock(return_value=[
{"id": "1", "addr": "127.0.0.1:54321", "cmd": "ping"},
])
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/clients",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# ── Permission checks ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_key_requires_admin_or_dba(client, developer_token, admin_token):
"""Non-admin/dba cannot create keys."""
# Need a redis instance first
iid_data = dict(REDIS_INSTANCE_DATA)
resp = await client.post(
"/api/v1/instances",
json=iid_data,
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
resp = await client.post(
"/api/v1/redis/" + str(iid) + "/keys",
json={"db": 0, "key": "k", "value": "v", "type": "string"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_flushdb_requires_admin(client, dba_token, admin_token):
"""Non-admin cannot flush DB."""
iid_data = dict(REDIS_INSTANCE_DATA)
resp = await client.post(
"/api/v1/instances",
json=iid_data,
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
resp = await client.delete(
"/api/v1/redis/" + str(iid) + "/flushdb?db=0",
headers=auth_header(dba_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_flushdb_admin_can(mock_cls, client, admin_token):
"""Admin can flush DB."""
mock_cls.flush_db = AsyncMock(return_value="OK")
iid = await _create_redis_instance(client, admin_token)
resp = await client.delete(
"/api/v1/redis/" + str(iid) + "/flushdb?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# ── Non-Redis instance rejection ──────────────────────────────────
@pytest.mark.asyncio
async def test_non_redis_instance_rejected(client, admin_token):
"""A MySQL instance should be rejected on Redis endpoints."""
mysql_data = {
"name": "Test MySQL",
"db_type": "mysql",
"host": "127.0.0.1",
"port": 3306,
"username": "root",
"password": "test123",
"database": "test_db",
"env": "development",
}
resp = await client.post(
"/api/v1/instances",
json=mysql_data,
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/info",
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "Redis" in resp.json()["detail"]