mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
🐛 fix(connection/elasticsearch): 修复索引加载超时与测试按钮卡顿
- 使用 CAT Indices 轻量枚举索引并兼容 ES 7.3 参数 - CAT 失败时在统一超时预算内回退 Alias API - 分离连接测试与保存状态并后台加载数据库列表 - 隔离过期测试请求,避免弹窗重开后状态污染 - 补充索引回退、超时预算与按钮状态回归测试
This commit is contained in:
@@ -24,6 +24,7 @@ const notifyStoreSubscribers = () => {
|
||||
};
|
||||
|
||||
let mockFormValues: Record<string, any> = {};
|
||||
let mockValidateFields: (() => Promise<void>) | undefined;
|
||||
|
||||
const antdMessage = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
@@ -223,7 +224,7 @@ vi.mock("antd", () => {
|
||||
const Switch = () => <button type="button">switch</button>;
|
||||
|
||||
const formApi = {
|
||||
validateFields: vi.fn(() => Promise.resolve()),
|
||||
validateFields: vi.fn(() => mockValidateFields?.() ?? Promise.resolve()),
|
||||
getFieldsValue: vi.fn(() => ({
|
||||
type: "mysql",
|
||||
timeout: 30,
|
||||
@@ -363,6 +364,7 @@ describe("ConnectionModal i18n", () => {
|
||||
storeState.updateConnection.mockReset();
|
||||
storeState.setLanguagePreference.mockClear();
|
||||
mockFormValues = {};
|
||||
mockValidateFields = undefined;
|
||||
setCurrentLanguage("zh-CN");
|
||||
});
|
||||
|
||||
@@ -1030,6 +1032,7 @@ describe("ConnectionModal i18n", () => {
|
||||
it("retranslates test failure feedback while preserving raw detail when language changes in-place", async () => {
|
||||
storeState.appearance.uiVersion = "legacy";
|
||||
setCurrentLanguage("zh-CN");
|
||||
backendApp.TestConnection.mockReset();
|
||||
backendApp.TestConnection.mockResolvedValue({
|
||||
success: false,
|
||||
message: "backend raw error: /tmp/app.db",
|
||||
@@ -1067,6 +1070,140 @@ describe("ConnectionModal i18n", () => {
|
||||
expect(pageText).toContain("backend raw error: /tmp/app.db");
|
||||
});
|
||||
|
||||
it("stops connection action loading before optional database discovery finishes", async () => {
|
||||
storeState.appearance.uiVersion = "legacy";
|
||||
setCurrentLanguage("zh-CN");
|
||||
backendApp.TestConnection.mockResolvedValue({
|
||||
success: true,
|
||||
message: "连接正常",
|
||||
});
|
||||
let resolveDatabases: ((value: { success: true; data: unknown[] }) => void) | undefined;
|
||||
backendApp.DBGetDatabases.mockReset();
|
||||
backendApp.DBGetDatabases.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveDatabases = resolve;
|
||||
}),
|
||||
);
|
||||
const { default: ConnectionModal } = await import("./ConnectionModal");
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ConnectionModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
initialValues={initialConnection("elasticsearch", { port: 9200 })}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "测试连接").props.onClick();
|
||||
await flushConnectionTestTick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer!.toJSON())).toContain("连接成功");
|
||||
expect(findButton(renderer!, "测试连接").props.disabled).toBe(false);
|
||||
expect(findButton(renderer!, "保存").props.disabled).toBe(false);
|
||||
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveDatabases?.({ success: true, data: [] });
|
||||
await flushConnectionTestTick();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stale validation run restart loading after the modal reopens", async () => {
|
||||
storeState.appearance.uiVersion = "legacy";
|
||||
setCurrentLanguage("zh-CN");
|
||||
let resolveValidation: (() => void) | undefined;
|
||||
mockValidateFields = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveValidation = resolve;
|
||||
});
|
||||
backendApp.TestConnection.mockReset();
|
||||
backendApp.TestConnection.mockRejectedValue(new Error("stale validation failure"));
|
||||
const { default: ConnectionModal } = await import("./ConnectionModal");
|
||||
const connection = initialConnection("elasticsearch", { port: 9200 });
|
||||
const onClose = vi.fn();
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ConnectionModal open onClose={onClose} initialValues={connection} />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "测试连接").props.onClick();
|
||||
await flushConnectionTestTick();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<ConnectionModal open={false} onClose={onClose} initialValues={connection} />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<ConnectionModal open onClose={onClose} initialValues={connection} />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
resolveValidation?.();
|
||||
await flushConnectionTestTick();
|
||||
});
|
||||
|
||||
expect(backendApp.TestConnection).not.toHaveBeenCalled();
|
||||
expect(textContent(renderer!.toJSON())).not.toContain("stale validation failure");
|
||||
expect(findButton(renderer!, "测试连接").props.disabled).toBe(false);
|
||||
expect(findButton(renderer!, "保存").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a stale connection-test rejection after the modal reopens", async () => {
|
||||
storeState.appearance.uiVersion = "legacy";
|
||||
setCurrentLanguage("zh-CN");
|
||||
let rejectConnection: ((reason?: unknown) => void) | undefined;
|
||||
backendApp.TestConnection.mockReset();
|
||||
backendApp.TestConnection.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectConnection = reject;
|
||||
}),
|
||||
);
|
||||
const { default: ConnectionModal } = await import("./ConnectionModal");
|
||||
const connection = initialConnection("elasticsearch", { port: 9200 });
|
||||
const onClose = vi.fn();
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ConnectionModal open onClose={onClose} initialValues={connection} />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "测试连接").props.onClick();
|
||||
await flushConnectionTestTick();
|
||||
});
|
||||
expect(backendApp.TestConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<ConnectionModal open={false} onClose={onClose} initialValues={connection} />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<ConnectionModal open onClose={onClose} initialValues={connection} />,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
rejectConnection?.(new Error("stale connection failure"));
|
||||
await flushConnectionTestTick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer!.toJSON())).not.toContain("stale connection failure");
|
||||
expect(findButton(renderer!, "测试连接").props.disabled).toBe(false);
|
||||
expect(findButton(renderer!, "保存").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("renders English data source groups and hints for the remaining step one copy", async () => {
|
||||
storeState.appearance.uiVersion = "legacy";
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
@@ -328,7 +328,8 @@ const ConnectionModal: React.FC<{
|
||||
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [useSSL, setUseSSL] = useState(false);
|
||||
const [useSSH, setUseSSH] = useState(false);
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
@@ -374,6 +375,7 @@ const ConnectionModal: React.FC<{
|
||||
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const testRunIdRef = useRef(0);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
const updateConnection = useStore((state) => state.updateConnection);
|
||||
const theme = useStore((state) => state.theme);
|
||||
@@ -1325,8 +1327,10 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
testRunIdRef.current += 1;
|
||||
if (open) {
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
setTestingConnection(false);
|
||||
testInFlightRef.current = false;
|
||||
if (testTimerRef.current !== null) {
|
||||
window.clearTimeout(testTimerRef.current);
|
||||
@@ -1664,6 +1668,7 @@ const ConnectionModal: React.FC<{
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
testRunIdRef.current += 1;
|
||||
if (testTimerRef.current !== null) {
|
||||
window.clearTimeout(testTimerRef.current);
|
||||
testTimerRef.current = null;
|
||||
@@ -1687,7 +1692,7 @@ const ConnectionModal: React.FC<{
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSaving(true);
|
||||
|
||||
const config = await buildConnectionConfig({
|
||||
values,
|
||||
@@ -1745,12 +1750,12 @@ const ConnectionModal: React.FC<{
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestTest = () => {
|
||||
if (loading) return;
|
||||
if (saving || testingConnection) return;
|
||||
if (testTimerRef.current !== null) return;
|
||||
testTimerRef.current = window.setTimeout(() => {
|
||||
testTimerRef.current = null;
|
||||
@@ -1802,13 +1807,17 @@ const ConnectionModal: React.FC<{
|
||||
const handleTest = async () => {
|
||||
if (testInFlightRef.current) return;
|
||||
testInFlightRef.current = true;
|
||||
const testRunId = ++testRunIdRef.current;
|
||||
const isCurrentTestRun = () => testRunIdRef.current === testRunId;
|
||||
try {
|
||||
await form.validateFields();
|
||||
if (!isCurrentTestRun()) return;
|
||||
const values = form.getFieldsValue(true);
|
||||
const unavailableReason = await resolveDriverUnavailableReason(
|
||||
values.type,
|
||||
values.driver,
|
||||
);
|
||||
if (!isCurrentTestRun()) return;
|
||||
if (unavailableReason) {
|
||||
applyTestFailureFeedback({
|
||||
kind: "driver_unavailable",
|
||||
@@ -1835,7 +1844,7 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTestingConnection(true);
|
||||
setTestResult(null);
|
||||
const config = await buildConnectionConfig({
|
||||
values,
|
||||
@@ -1843,6 +1852,7 @@ const ConnectionModal: React.FC<{
|
||||
initialValues,
|
||||
translate: t,
|
||||
});
|
||||
if (!isCurrentTestRun()) return;
|
||||
if (initialValues?.id) {
|
||||
config.id = initialValues.id;
|
||||
}
|
||||
@@ -1868,79 +1878,100 @@ const ConnectionModal: React.FC<{
|
||||
t("connection.modal.test.timeout", { seconds: timeoutSeconds }),
|
||||
);
|
||||
|
||||
if (!isCurrentTestRun()) return;
|
||||
|
||||
if (res.success) {
|
||||
void message.destroy("connection-test-failure");
|
||||
setTestResult({ type: "success", message: res.message });
|
||||
if (isRedisType) {
|
||||
const dbRes = await withClientTimeout(
|
||||
RedisGetDatabases(config as any),
|
||||
rpcTimeoutMs,
|
||||
t("connection.modal.test.redis_database_list_timeout", {
|
||||
seconds: timeoutSeconds,
|
||||
}),
|
||||
);
|
||||
if (dbRes.success) {
|
||||
const supportedDbs = extractRedisDatabaseList(dbRes.data);
|
||||
setRedisDbList(supportedDbs);
|
||||
form.setFieldValue(
|
||||
"includeRedisDatabases",
|
||||
normalizeRedisDatabaseSelection(
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
supportedDbs,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setRedisDbList(
|
||||
buildRedisDatabaseList(
|
||||
config.redisDB,
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
),
|
||||
);
|
||||
message.warning(
|
||||
t("connection.modal.test.redis_database_list_failure", {
|
||||
detail: normalizeConnectionSecretErrorMessage(
|
||||
dbRes.message,
|
||||
t("connection.modal.error.unknown"),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (!isJVMType) {
|
||||
// Other databases: fetch database list
|
||||
const dbRes = await withClientTimeout(
|
||||
DBGetDatabases(dbTestConfig as any),
|
||||
rpcTimeoutMs,
|
||||
t("connection.modal.test.databaseListTimeout", {
|
||||
seconds: timeoutSeconds,
|
||||
}),
|
||||
);
|
||||
if (dbRes.success) {
|
||||
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
|
||||
const dbs = dbRows
|
||||
.map((row: any) => row?.Database || row?.database)
|
||||
.filter(
|
||||
(name: any) => typeof name === "string" && name.trim() !== "",
|
||||
void (async () => {
|
||||
try {
|
||||
if (isRedisType) {
|
||||
const dbRes = await withClientTimeout(
|
||||
RedisGetDatabases(config as any),
|
||||
rpcTimeoutMs,
|
||||
t("connection.modal.test.redis_database_list_timeout", {
|
||||
seconds: timeoutSeconds,
|
||||
}),
|
||||
);
|
||||
setDbList(dbs);
|
||||
if (dbs.length === 0) {
|
||||
message.warning(
|
||||
values.type === "dameng"
|
||||
? t("connection.modal.test.noVisibleSchema")
|
||||
: t("connection.modal.test.noVisibleDatabaseList"),
|
||||
if (!isCurrentTestRun()) return;
|
||||
if (dbRes.success) {
|
||||
const supportedDbs = extractRedisDatabaseList(dbRes.data);
|
||||
setRedisDbList(supportedDbs);
|
||||
form.setFieldValue(
|
||||
"includeRedisDatabases",
|
||||
normalizeRedisDatabaseSelection(
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
supportedDbs,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setRedisDbList(
|
||||
buildRedisDatabaseList(
|
||||
config.redisDB,
|
||||
form.getFieldValue("includeRedisDatabases"),
|
||||
),
|
||||
);
|
||||
message.warning(
|
||||
t("connection.modal.test.redis_database_list_failure", {
|
||||
detail: normalizeConnectionSecretErrorMessage(
|
||||
dbRes.message,
|
||||
t("connection.modal.error.unknown"),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (!isJVMType) {
|
||||
const dbRes = await withClientTimeout(
|
||||
DBGetDatabases(dbTestConfig as any),
|
||||
rpcTimeoutMs,
|
||||
t("connection.modal.test.databaseListTimeout", {
|
||||
seconds: timeoutSeconds,
|
||||
}),
|
||||
);
|
||||
if (!isCurrentTestRun()) return;
|
||||
if (dbRes.success) {
|
||||
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
|
||||
const dbs = dbRows
|
||||
.map((row: any) => row?.Database || row?.database)
|
||||
.filter(
|
||||
(name: any) =>
|
||||
typeof name === "string" && name.trim() !== "",
|
||||
);
|
||||
setDbList(dbs);
|
||||
if (dbs.length === 0) {
|
||||
message.warning(
|
||||
values.type === "dameng"
|
||||
? t("connection.modal.test.noVisibleSchema")
|
||||
: t("connection.modal.test.noVisibleDatabaseList"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setDbList([]);
|
||||
message.warning(
|
||||
t("connection.modal.test.databaseListFailure", {
|
||||
detail: normalizeConnectionSecretErrorMessage(
|
||||
dbRes.message,
|
||||
t("connection.modal.error.unknown"),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setDbList([]);
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentTestRun()) return;
|
||||
const detail = normalizeConnectionSecretErrorMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
t("connection.modal.error.unknown"),
|
||||
);
|
||||
message.warning(
|
||||
t("connection.modal.test.databaseListFailure", {
|
||||
detail: normalizeConnectionSecretErrorMessage(
|
||||
dbRes.message,
|
||||
t("connection.modal.error.unknown"),
|
||||
),
|
||||
}),
|
||||
isRedisType
|
||||
? t("connection.modal.test.redis_database_list_failure", {
|
||||
detail,
|
||||
})
|
||||
: t("connection.modal.test.databaseListFailure", { detail }),
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
applyTestFailureFeedback({
|
||||
kind: "runtime",
|
||||
@@ -1949,6 +1980,7 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (!isCurrentTestRun()) return;
|
||||
if (e && typeof e === "object" && "errorFields" in e) {
|
||||
applyTestFailureFeedback({
|
||||
kind: "validation",
|
||||
@@ -1968,8 +2000,10 @@ const ConnectionModal: React.FC<{
|
||||
fallbackKey: "connection.modal.test.fallback.unknownException",
|
||||
});
|
||||
} finally {
|
||||
testInFlightRef.current = false;
|
||||
setLoading(false);
|
||||
if (isCurrentTestRun()) {
|
||||
testInFlightRef.current = false;
|
||||
setTestingConnection(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2708,8 +2742,8 @@ const ConnectionModal: React.FC<{
|
||||
<Space size={8} style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
key="test"
|
||||
loading={loading}
|
||||
disabled={operationBlocked}
|
||||
loading={testingConnection}
|
||||
disabled={operationBlocked || saving}
|
||||
onClick={requestTest}
|
||||
>
|
||||
{t("connection.action.test")}
|
||||
@@ -2720,8 +2754,8 @@ const ConnectionModal: React.FC<{
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
disabled={operationBlocked}
|
||||
loading={saving}
|
||||
disabled={operationBlocked || testingConnection}
|
||||
onClick={handleOk}
|
||||
>
|
||||
{t("common.action.save")}
|
||||
|
||||
@@ -23,7 +23,7 @@ func init() {
|
||||
"tdengine": "src-779b9b537f08856f",
|
||||
"iotdb": "src-7edea4aba8d4869e",
|
||||
"clickhouse": "src-d4150c3fb3d1313a",
|
||||
"elasticsearch": "src-8086b38d85310b20",
|
||||
"elasticsearch": "src-3dc1697786483347",
|
||||
"trino": "src-ba947f211ce7b19f",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -20,19 +21,32 @@ import (
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
|
||||
"github.com/elastic/go-elasticsearch/v8"
|
||||
"github.com/elastic/go-elasticsearch/v8/esapi"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEsPingTimeout = 5 * time.Second
|
||||
defaultEsQueryTimeout = 30 * time.Second
|
||||
defaultEsPingTimeout = 5 * time.Second
|
||||
defaultEsQueryTimeout = 30 * time.Second
|
||||
defaultEsIndexListTimeout = 10 * time.Second
|
||||
maxEsCatIndexListTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// ElasticsearchDB 实现 Database 接口,提供 Elasticsearch 数据源连接能力。
|
||||
type ElasticsearchDB struct {
|
||||
client *elasticsearch.Client
|
||||
database string // 默认索引名
|
||||
pingTimeout time.Duration
|
||||
forwarder *ssh.LocalForwarder
|
||||
client *elasticsearch.Client
|
||||
database string // 默认索引名
|
||||
pingTimeout time.Duration
|
||||
indexListTimeout time.Duration // 0 表示使用默认索引枚举总超时
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
type esHTTPStatusError struct {
|
||||
statusCode int
|
||||
status string
|
||||
}
|
||||
|
||||
func (e *esHTTPStatusError) Error() string {
|
||||
return e.status
|
||||
}
|
||||
|
||||
// Connect 建立到 Elasticsearch 集群的连接。
|
||||
@@ -327,10 +341,95 @@ func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
|
||||
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
totalTimeout := e.indexListTimeout
|
||||
if totalTimeout <= 0 {
|
||||
totalTimeout = defaultEsIndexListTimeout
|
||||
}
|
||||
deadline := time.Now().Add(totalTimeout)
|
||||
catTimeout := totalTimeout / 2
|
||||
if catTimeout > maxEsCatIndexListTimeout {
|
||||
catTimeout = maxEsCatIndexListTimeout
|
||||
}
|
||||
if catTimeout <= 0 {
|
||||
catTimeout = totalTimeout
|
||||
}
|
||||
|
||||
// Alias API 只返回索引名和别名,避免列表加载下载全部 settings/mappings。
|
||||
catCtx, cancelCat := context.WithTimeout(context.Background(), catTimeout)
|
||||
indices, catErr := e.getDatabasesViaCat(catCtx)
|
||||
cancelCat()
|
||||
if catErr == nil {
|
||||
return normalizeESIndexNames(indices), nil
|
||||
}
|
||||
|
||||
logger.Warnf("Elasticsearch CAT 索引枚举失败,回退 Alias API:%v", catErr)
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil, fmt.Errorf("获取索引列表失败:CAT Indices API: %v;Alias API: 总超时 %s 已耗尽", catErr, totalTimeout)
|
||||
}
|
||||
|
||||
aliasCtx, cancelAlias := context.WithTimeout(context.Background(), remaining)
|
||||
indices, aliasErr := e.getDatabasesViaAlias(aliasCtx)
|
||||
cancelAlias()
|
||||
if aliasErr == nil {
|
||||
return normalizeESIndexNames(indices), nil
|
||||
}
|
||||
return nil, fmt.Errorf("获取索引列表失败:CAT Indices API: %v;Alias API: %v", catErr, aliasErr)
|
||||
}
|
||||
|
||||
func (e *ElasticsearchDB) getDatabasesViaCat(ctx context.Context) ([]string, error) {
|
||||
indices, err := e.getDatabasesViaCatRequest(ctx, true)
|
||||
if err == nil {
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
var statusErr *esHTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr.statusCode != http.StatusBadRequest {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indices, compatibilityErr := e.getDatabasesViaCatRequest(ctx, false)
|
||||
if compatibilityErr != nil {
|
||||
return nil, fmt.Errorf("全量通配请求: %v;旧版兼容请求: %v", err, compatibilityErr)
|
||||
}
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
func (e *ElasticsearchDB) getDatabasesViaCatRequest(ctx context.Context, expandAll bool) ([]string, error) {
|
||||
options := []func(*esapi.CatIndicesRequest){
|
||||
e.client.Cat.Indices.WithContext(ctx),
|
||||
e.client.Cat.Indices.WithFormat("json"),
|
||||
e.client.Cat.Indices.WithH("index"),
|
||||
}
|
||||
if expandAll {
|
||||
options = append(options, e.client.Cat.Indices.WithExpandWildcards("all"))
|
||||
}
|
||||
|
||||
res, err := e.client.Cat.Indices(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.IsError() {
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
return nil, &esHTTPStatusError{statusCode: res.StatusCode, status: res.Status()}
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
Index string `json:"index"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&rows); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败:%w", err)
|
||||
}
|
||||
|
||||
indices := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
indices = append(indices, row.Index)
|
||||
}
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
func (e *ElasticsearchDB) getDatabasesViaAlias(ctx context.Context) ([]string, error) {
|
||||
res, err := e.client.Indices.GetAlias(
|
||||
e.client.Indices.GetAlias.WithContext(ctx),
|
||||
e.client.Indices.GetAlias.WithIndex("*"),
|
||||
@@ -339,26 +438,43 @@ func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
|
||||
e.client.Indices.GetAlias.WithIgnoreUnavailable(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取索引列表失败:%w", err)
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.IsError() {
|
||||
return nil, fmt.Errorf("获取索引列表失败:%s", res.Status())
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
return nil, &esHTTPStatusError{statusCode: res.StatusCode, status: res.Status()}
|
||||
}
|
||||
|
||||
var indexMap map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&indexMap); err != nil {
|
||||
return nil, fmt.Errorf("解析索引列表失败:%w", err)
|
||||
return nil, fmt.Errorf("解析响应失败:%w", err)
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(indexMap))
|
||||
indices := make([]string, 0, len(indexMap))
|
||||
for name := range indexMap {
|
||||
if name := strings.TrimSpace(name); name != "" {
|
||||
result = append(result, name)
|
||||
}
|
||||
indices = append(indices, name)
|
||||
}
|
||||
return result, nil
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
func normalizeESIndexNames(indices []string) []string {
|
||||
seen := make(map[string]struct{}, len(indices))
|
||||
result := make([]string, 0, len(indices))
|
||||
for _, index := range indices {
|
||||
name := strings.TrimSpace(index)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
result = append(result, name)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTables 对 ES 而言索引即表,返回索引自身名称及别名。
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
|
||||
@@ -174,22 +175,28 @@ func TestElasticsearchConnectRejectsFailedPing(t *testing.T) {
|
||||
|
||||
// TestElasticsearchGetDatabases 测试获取索引列表。
|
||||
func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
t.Run("使用轻量别名端点获取全部索引", func(t *testing.T) {
|
||||
var fullIndexDefinitionsRequested atomic.Bool
|
||||
t.Run("现代版本使用 CAT 全量通配获取全部索引", func(t *testing.T) {
|
||||
var aliasListingRequested atomic.Bool
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/*/_alias" {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"logs-2024": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
"users": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
".security": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
".kibana_1": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
"products": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/_cat/indices" {
|
||||
query := r.URL.Query()
|
||||
if query.Get("format") != "json" || query.Get("h") != "index" || query.Get("expand_wildcards") != "all" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, []map[string]string{
|
||||
{"index": "users"},
|
||||
{"index": ".security"},
|
||||
{"index": "logs-2024"},
|
||||
{"index": "users"},
|
||||
{"index": ".kibana_1"},
|
||||
{"index": "products"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/*" {
|
||||
fullIndexDefinitionsRequested.Store(true)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/*/_alias" {
|
||||
aliasListingRequested.Store(true)
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -201,7 +208,6 @@ func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
t.Fatalf("GetDatabases 失败:%v", err)
|
||||
}
|
||||
|
||||
slices.Sort(databases)
|
||||
expected := []string{".kibana_1", ".security", "logs-2024", "products", "users"}
|
||||
if len(databases) != len(expected) {
|
||||
t.Fatalf("期望 %d 个索引,实际 %d:%v", len(expected), len(databases), databases)
|
||||
@@ -211,23 +217,59 @@ func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
t.Fatalf("索引 [%d] 期望 %q,实际 %q", i, name, databases[i])
|
||||
}
|
||||
}
|
||||
if fullIndexDefinitionsRequested.Load() {
|
||||
t.Fatal("GetDatabases 不应请求包含 settings/mappings 的完整索引定义")
|
||||
if aliasListingRequested.Load() {
|
||||
t.Fatal("CAT 端点成功时不应继续请求 Alias API")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ES 7.3 拒绝全量通配参数后重试兼容 CAT 请求", func(t *testing.T) {
|
||||
var catListingAttempts atomic.Int32
|
||||
var aliasListingRequested atomic.Bool
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/_cat/indices":
|
||||
catListingAttempts.Add(1)
|
||||
query := r.URL.Query()
|
||||
if query.Has("expand_wildcards") {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if query.Get("format") != "json" || query.Get("h") != "index" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, []map[string]string{{"index": "legacy-events"}})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/*/_alias":
|
||||
aliasListingRequested.Store(true)
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "")
|
||||
databases, err := db.GetDatabases()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDatabases 应兼容 ES 7.3 CAT 参数:%v", err)
|
||||
}
|
||||
if attempts := catListingAttempts.Load(); attempts != 2 {
|
||||
t.Fatalf("期望先尝试现代 CAT 再重试兼容请求,实际请求 %d 次", attempts)
|
||||
}
|
||||
if aliasListingRequested.Load() {
|
||||
t.Fatal("兼容 CAT 请求成功时不应回退 Alias API")
|
||||
}
|
||||
if !slices.Equal(databases, []string{"legacy-events"}) {
|
||||
t.Fatalf("期望 [legacy-events],实际 %v", databases)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("允许没有索引的空集群", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/*/_alias" {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/_cat/indices" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if query.Get("allow_no_indices") != "true" || query.Get("ignore_unavailable") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{})
|
||||
writeJSON(w, []map[string]string{})
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "")
|
||||
@@ -240,6 +282,76 @@ func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CAT 端点不可用时回退到 Alias API", func(t *testing.T) {
|
||||
var catListingRequested atomic.Bool
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/_cat/indices":
|
||||
catListingRequested.Store(true)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/*/_alias":
|
||||
query := r.URL.Query()
|
||||
if query.Get("allow_no_indices") != "true" || query.Get("ignore_unavailable") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"archive": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
"orders": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "")
|
||||
databases, err := db.GetDatabases()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDatabases 应在 CAT 被拒绝时回退:%v", err)
|
||||
}
|
||||
if !catListingRequested.Load() {
|
||||
t.Fatal("GetDatabases 应先尝试 CAT 端点")
|
||||
}
|
||||
expected := []string{"archive", "orders"}
|
||||
if !slices.Equal(databases, expected) {
|
||||
t.Fatalf("期望 %v,实际 %v", expected, databases)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CAT 超时后使用剩余预算回退到 Alias API", func(t *testing.T) {
|
||||
var aliasListingRequested atomic.Bool
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/_cat/indices":
|
||||
<-r.Context().Done()
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/*/_alias":
|
||||
aliasListingRequested.Store(true)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"events": map[string]interface{}{"aliases": map[string]interface{}{}},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "")
|
||||
db.indexListTimeout = 500 * time.Millisecond
|
||||
started := time.Now()
|
||||
databases, err := db.GetDatabases()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDatabases 应在 CAT 超时后回退:%v", err)
|
||||
}
|
||||
if !aliasListingRequested.Load() {
|
||||
t.Fatal("CAT 超时后应使用新的上下文请求 Alias API")
|
||||
}
|
||||
if !slices.Equal(databases, []string{"events"}) {
|
||||
t.Fatalf("期望 [events],实际 %v", databases)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= db.indexListTimeout {
|
||||
t.Fatalf("回退不应耗尽总超时预算,实际耗时 %s", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("连接未打开时返回错误", func(t *testing.T) {
|
||||
db := &ElasticsearchDB{}
|
||||
_, err := db.GetDatabases()
|
||||
@@ -1099,6 +1211,14 @@ func TestESMockIntegration(t *testing.T) {
|
||||
case r.Method == http.MethodHead && path == "/":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Cat.Indices — 仅返回索引名,兼容 ES 6/7/8。
|
||||
case r.Method == http.MethodGet && path == "/_cat/indices":
|
||||
writeJSON(w, []map[string]string{
|
||||
{"index": "products"},
|
||||
{"index": "orders"},
|
||||
{"index": ".internal"},
|
||||
})
|
||||
|
||||
// 完整索引定义响应可能过大,列表加载不应请求该端点。
|
||||
case r.Method == http.MethodGet && path == "/*":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
Reference in New Issue
Block a user