fix release v2 review issues
This commit is contained in:
1 parent
ef2ed6c602
commit
ca856f9f54
29 files changed
+508
-131
No files matched your search
@@ -53,7 +53,10 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
|
||||
const nameIntegration = nameIntegrationMatches.length === 1 ? nameIntegrationMatches[0] : undefined;
|
||||
const nameIntegration =
|
||||
exactIntegrationMatches.length === 0 && nameIntegrationMatches.length === 1
|
||||
? nameIntegrationMatches[0]
|
||||
: undefined;
|
||||
const integration = exactIntegration ?? nameIntegration;
|
||||
const exactAppMatches = apps.filter(({ href }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(href);
|
||||
@@ -63,11 +66,16 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
|
||||
const nameApp = nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
|
||||
const nameApp = exactAppMatches.length === 0 && nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
|
||||
const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined;
|
||||
const app = linkedApp ?? exactApp ?? nameApp;
|
||||
const exactUrl = Boolean(exactIntegration || exactApp);
|
||||
const linked = Boolean(integration?.appId && linkedApp);
|
||||
const integrationAmbiguous =
|
||||
exactIntegrationMatches.length > 1 || (exactIntegrationMatches.length === 0 && nameIntegrationMatches.length > 1);
|
||||
const appAmbiguous =
|
||||
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
|
||||
const ambiguous = integrationAmbiguous || appAmbiguous;
|
||||
const state: DockerReconciliationState = linked
|
||||
? "linked"
|
||||
: integration && !exactUrl
|
||||
@@ -87,7 +95,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
match,
|
||||
urlCandidates,
|
||||
state,
|
||||
nextAction: nextActionForState(state),
|
||||
nextAction: integrationAmbiguous
|
||||
? ("reviewIntegration" as const)
|
||||
: appAmbiguous
|
||||
? ("viewRepresentation" as const)
|
||||
: nextActionForState(state),
|
||||
representation: {
|
||||
integration: integration
|
||||
? {
|
||||
@@ -105,11 +117,7 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
exactUrl,
|
||||
nameMatch: Boolean(nameIntegration || nameApp),
|
||||
linked,
|
||||
ambiguous:
|
||||
exactIntegrationMatches.length > 1 ||
|
||||
nameIntegrationMatches.length > 1 ||
|
||||
exactAppMatches.length > 1 ||
|
||||
nameAppMatches.length > 1,
|
||||
ambiguous,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -148,7 +156,7 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
Boolean(candidate.representation.integration),
|
||||
),
|
||||
nextAction:
|
||||
candidate.state === "moved"
|
||||
candidate.nextAction === "reviewIntegration"
|
||||
? ("reviewIntegration" as const)
|
||||
: candidate.state === "newRecognized"
|
||||
? ("setupIntegration" as const)
|
||||
@@ -159,9 +167,11 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("testConnection" as const)
|
||||
: candidate.match
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
: candidate.nextAction === "reviewIntegration"
|
||||
? ("reviewIntegration" as const)
|
||||
: candidate.match
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "apiRequest" as const,
|
||||
@@ -173,7 +183,11 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
{
|
||||
layer: "appRepresentation" as const,
|
||||
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
|
||||
nextAction: candidate.representation.app ? ("none" as const) : ("createApp" as const),
|
||||
nextAction: candidate.representation.app
|
||||
? ("none" as const)
|
||||
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
|
||||
? ("viewRepresentation" as const)
|
||||
: ("createApp" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetConfiguration" as const,
|
||||
|
||||
@@ -29,6 +29,12 @@ export const dockerRouter = createTRPCRouter({
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
|
||||
refreshInventory: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(() => {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
}),
|
||||
getContainers: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
|
||||
@@ -31,6 +31,10 @@ export class KubernetesClient {
|
||||
return KubernetesClient.getRegistry().getClient(contextId);
|
||||
}
|
||||
|
||||
public static getDefaultInstance(): KubernetesClient {
|
||||
return KubernetesClient.getRegistry().getDefaultClient();
|
||||
}
|
||||
|
||||
public static async getContextsAsync() {
|
||||
return await KubernetesClient.getRegistry().getContextsAsync();
|
||||
}
|
||||
@@ -68,6 +72,10 @@ export class KubernetesClientRegistry {
|
||||
return client;
|
||||
}
|
||||
|
||||
public getDefaultClient() {
|
||||
return this.getClient(this.defaultContextId);
|
||||
}
|
||||
|
||||
public async getContextsAsync() {
|
||||
const contexts = await Promise.all(
|
||||
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export const probeRuntimeCapabilitiesAsync = async ({
|
||||
workshopApiUrl,
|
||||
fetchImpl = fetch,
|
||||
getKubernetesVersionAsync = async (signal) =>
|
||||
await KubernetesClient.getInstance().versionApi.getCode({
|
||||
await KubernetesClient.getDefaultInstance().versionApi.getCode({
|
||||
middleware: [
|
||||
{
|
||||
pre: async (request: RequestContext) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dockerRouter } from "../../docker/docker-router";
|
||||
|
||||
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
|
||||
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
|
||||
const getDockerDataAsyncMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => Promise<{
|
||||
@@ -75,6 +76,7 @@ vi.mock("@homarr/request-handler/docker", () => ({
|
||||
return () => undefined;
|
||||
},
|
||||
dockerContainersRequestHandler: {
|
||||
invalidateCache: invalidateDockerCacheMock,
|
||||
handler: () => ({
|
||||
getDataAsync: getDockerDataAsyncMock,
|
||||
}),
|
||||
@@ -103,6 +105,7 @@ const validInputs: {
|
||||
} = {
|
||||
reconcileServices: undefined,
|
||||
getServiceHealth: undefined,
|
||||
refreshInventory: undefined,
|
||||
getContainers: undefined,
|
||||
startAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
stopAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
@@ -317,6 +320,136 @@ test("does not adopt an ambiguous existing integration", async () => {
|
||||
expect(result.candidates[0]?.representation.integration).toBeNull();
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(result.candidates[0]?.state).toBe("newRecognized");
|
||||
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
|
||||
});
|
||||
|
||||
test("prefers a unique exact integration match over duplicate name matches", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "exact",
|
||||
name: "Primary",
|
||||
kind: "sonarr",
|
||||
url: "http://home.example:8989",
|
||||
appId: null,
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
{
|
||||
id: "name-one",
|
||||
name: "sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://other.example:8989",
|
||||
appId: null,
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
{
|
||||
id: "name-two",
|
||||
name: "sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://third.example:8989",
|
||||
appId: null,
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.integration?.id).toBe("exact");
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false);
|
||||
});
|
||||
|
||||
test("does not fall back to a unique name when exact integration matches are ambiguous", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "named", name: "sonarr", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
{ id: "other", name: "Other", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.integration).toBeNull();
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
|
||||
});
|
||||
|
||||
test("prefers an explicitly linked app over duplicate app matches", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://home.example:8989",
|
||||
appId: "linked",
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
]);
|
||||
findAppsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "linked", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
|
||||
{ id: "duplicate", name: "Sonarr duplicate", href: "http://home.example:8989", iconUrl: "" },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.app?.id).toBe("linked");
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false);
|
||||
expect(result.candidates[0]?.state).toBe("linked");
|
||||
});
|
||||
|
||||
test("does not offer to create an app when existing app matches are ambiguous", async () => {
|
||||
const dockerData = {
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
} satisfies Awaited<ReturnType<typeof getDockerDataAsyncMock>>;
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce(dockerData).mockResolvedValueOnce(dockerData);
|
||||
const apps = [
|
||||
{ id: "first", name: "Sonarr one", href: "http://home.example:8989", iconUrl: "" },
|
||||
{ id: "second", name: "Sonarr two", href: "http://home.example:8989", iconUrl: "" },
|
||||
] satisfies Awaited<ReturnType<typeof findAppsAsyncMock>>;
|
||||
findAppsAsyncMock.mockResolvedValueOnce(apps).mockResolvedValueOnce(apps);
|
||||
|
||||
const reconciliation = await createAdminCaller().reconcileServices();
|
||||
const health = await createAdminCaller().getServiceHealth();
|
||||
|
||||
expect(reconciliation.candidates[0]?.representation.app).toBeNull();
|
||||
expect(reconciliation.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(reconciliation.candidates[0]?.nextAction).toBe("viewRepresentation");
|
||||
expect(health.services[0]?.layers).toContainEqual({
|
||||
layer: "appRepresentation",
|
||||
status: "missing",
|
||||
nextAction: "viewRepresentation",
|
||||
});
|
||||
});
|
||||
|
||||
test("invalidates the shared Docker inventory on explicit refresh", async () => {
|
||||
await createAdminCaller().refreshInventory();
|
||||
|
||||
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("projects persisted service layers without inventing runtime health", async () => {
|
||||
|
||||
Reference in new issue
Block a user