From e3ef826f528c2ae24936c2989ff4548ab27fc58b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 19 Sep 2023 13:50:01 +0100 Subject: [PATCH] Update qovery integration --- .../v1/integrationAuthController.ts | 215 +++++++++++------- .../controllers/v1/integrationController.ts | 4 +- backend/src/integrations/sync.ts | 7 +- backend/src/models/integration/integration.ts | 8 + backend/src/models/integration/types.ts | 4 - backend/src/validation/integration.ts | 8 +- .../src/hooks/api/integrationAuth/queries.tsx | 12 +- .../src/hooks/api/integrations/queries.tsx | 10 +- .../src/pages/integrations/qovery/create.tsx | 122 +++++----- .../IntegrationsSection.tsx | 6 +- 10 files changed, 222 insertions(+), 174 deletions(-) diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index ede705b8f..a48c01678 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -406,7 +406,7 @@ export const getIntegrationAuthQoveryProjects = async (req: Request, res: Respon params: { integrationAuthId }, query: { orgId } } = await validateRequest(reqValidator.GetIntegrationAuthQoveryProjectsV1, req); - + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ integrationAuthId: new ObjectId(integrationAuthId) @@ -420,28 +420,37 @@ export const getIntegrationAuthQoveryProjects = async (req: Request, res: Respon ProjectPermissionActions.Read, ProjectPermissionSub.Integrations ); - - const { data } = await standardRequest.get( - `${INTEGRATION_QOVERY_API_URL}/organization/${orgId}/project`, - { - headers: { - Authorization: `Token ${accessToken}`, - "Accept": "application/json", - }, - } - ); + + interface Project { + name: string; + projectId: string; + } interface QoveryProject { id: string; name: string; } + + let projects: Project[] = []; + + if (orgId && orgId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization/${orgId}/project`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); - const projects = data.results.map((a: QoveryProject) => { - return { - name: a.name, - projectId: a.id, - }; - }); + projects = data.results.map((a: QoveryProject) => { + return { + name: a.name, + projectId: a.id, + }; + }); + } return res.status(200).send({ projects @@ -449,7 +458,7 @@ export const getIntegrationAuthQoveryProjects = async (req: Request, res: Respon }; /** - * Return list of Qovery Environments for a specific projectId + * Return list of Qovery environments for project with id [projectId] * @param req * @param res */ @@ -472,28 +481,37 @@ export const getIntegrationAuthQoveryEnvironments = async (req: Request, res: Re ProjectPermissionActions.Read, ProjectPermissionSub.Integrations ); - - const { data } = await standardRequest.get( - `${INTEGRATION_QOVERY_API_URL}/project/${projectId}/environment`, - { - headers: { - Authorization: `Token ${accessToken}`, - "Accept": "application/json", - }, - } - ); + + interface Environment { + name: string; + environmentId: string; + } interface QoveryEnvironment { id: string; name: string; } + + let environments: Environment[] = []; + + if (projectId && projectId !== "" && projectId !== "none") { // TODO: fix + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/project/${projectId}/environment`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); - const environments = data.results.map((a: QoveryEnvironment) => { - return { - name: a.name, - environmentId: a.id, - }; - }); + environments = data.results.map((a: QoveryEnvironment) => { + return { + name: a.name, + environmentId: a.id, + }; + }); + } return res.status(200).send({ environments @@ -501,7 +519,7 @@ export const getIntegrationAuthQoveryEnvironments = async (req: Request, res: Re }; /** - * Return list of Qovery Apps for a specific environmentId + * Return list of Qovery apps for environment with id [environmentId] * @param req * @param res */ @@ -524,28 +542,37 @@ export const getIntegrationAuthQoveryApps = async (req: Request, res: Response) ProjectPermissionActions.Read, ProjectPermissionSub.Integrations ); - - const { data } = await standardRequest.get( - `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/application`, - { - headers: { - Authorization: `Token ${accessToken}`, - "Accept": "application/json", - }, - } - ); + + interface App { + name: string; + appId: string; + } interface QoveryApp { id: string; name: string; } + + let apps: App[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/application`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); - const apps = data.results.map((a: QoveryApp) => { - return { - name: a.name, - appId: a.id, - }; - }); + apps = data.results.map((a: QoveryApp) => { + return { + name: a.name, + appId: a.id, + }; + }); + } return res.status(200).send({ apps @@ -553,7 +580,7 @@ export const getIntegrationAuthQoveryApps = async (req: Request, res: Response) }; /** - * Return list of Qovery Containers for a specific environmentId + * Return list of Qovery containers for environment with id [environmentId] * @param req * @param res */ @@ -576,28 +603,37 @@ export const getIntegrationAuthQoveryContainers = async (req: Request, res: Resp ProjectPermissionActions.Read, ProjectPermissionSub.Integrations ); - - const { data } = await standardRequest.get( - `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/container`, - { - headers: { - Authorization: `Token ${accessToken}`, - "Accept": "application/json", - }, - } - ); + + interface Container { + name: string; + appId: string; + } interface QoveryContainer { id: string; name: string; } + + let containers: Container[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/container`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); - const containers = data.results.map((a: QoveryContainer) => { - return { - name: a.name, - appId: a.id, - }; - }); + containers = data.results.map((a: QoveryContainer) => { + return { + name: a.name, + appId: a.id, + }; + }); + } return res.status(200).send({ containers @@ -605,7 +641,7 @@ export const getIntegrationAuthQoveryContainers = async (req: Request, res: Resp }; /** - * Return list of Qovery Jobs for a specific environmentId + * Return list of Qovery jobs for environment with id [environmentId] * @param req * @param res */ @@ -628,28 +664,37 @@ export const getIntegrationAuthQoveryJobs = async (req: Request, res: Response) ProjectPermissionActions.Read, ProjectPermissionSub.Integrations ); - - const { data } = await standardRequest.get( - `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/job`, - { - headers: { - Authorization: `Token ${accessToken}`, - "Accept": "application/json", - }, - } - ); + interface Job { + name: string; + appId: string; + } + interface QoveryJob { id: string; name: string; } + + let jobs: Job[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/job`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); - const jobs = data.results.map((a: QoveryJob) => { - return { - name: a.name, - appId: a.id, - }; - }); + jobs = data.results.map((a: QoveryJob) => { + return { + name: a.name, + appId: a.id, + }; + }); + } return res.status(200).send({ jobs diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index b847b382c..e709d9d0c 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -34,6 +34,7 @@ export const createIntegration = async (req: Request, res: Response) => { appId, owner, region, + scope, targetService, targetServiceId, integrationAuthId, @@ -42,7 +43,7 @@ export const createIntegration = async (req: Request, res: Response) => { metadata } } = await validateRequest(reqValidator.CreateIntegrationV1, req); - + const integrationAuth = await IntegrationAuth.findById(integrationAuthId) .populate<{ workspace: IWorkspace }>("workspace") .select( @@ -90,6 +91,7 @@ export const createIntegration = async (req: Request, res: Response) => { owner, path, region, + scope, secretPath, integration: integrationAuth.integration, integrationAuth: new Types.ObjectId(integrationAuthId), diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 220ff3544..9cd92af09 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -2151,8 +2151,9 @@ const syncSecretsQovery = async ({ secrets: Record; accessToken: string; }) => { + const getSecretsRes = ( - await standardRequest.get(`${INTEGRATION_QOVERY_API_URL}/${integration.metadata?.scope?.toLowerCase()}/${integration.appId}/environmentVariable`, { + await standardRequest.get(`${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable`, { headers: { Authorization: `Token ${accessToken}`, "Accept-Encoding": "application/json" @@ -2172,7 +2173,7 @@ const syncSecretsQovery = async ({ // case: secret does not exist in qovery // -> add secret await standardRequest.post( - `${INTEGRATION_QOVERY_API_URL}/${integration.metadata?.scope?.toLowerCase()}/${integration.appId}/environmentVariable`, + `${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable`, { key, value: secrets[key].value @@ -2191,7 +2192,7 @@ const syncSecretsQovery = async ({ if (secrets[key].value !== getSecretsRes[key].value) { await standardRequest.put( - `${INTEGRATION_QOVERY_API_URL}/${integration.metadata?.scope?.toLowerCase()}/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, + `${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, { key, value: secrets[key].value diff --git a/backend/src/models/integration/integration.ts b/backend/src/models/integration/integration.ts index b1f508a3b..7b9957393 100644 --- a/backend/src/models/integration/integration.ts +++ b/backend/src/models/integration/integration.ts @@ -46,6 +46,7 @@ export interface IIntegration { targetServiceId: string; path: string; region: string; + scope: string; secretPath: string; integration: | "azure-key-vault" @@ -121,11 +122,13 @@ const integrationSchema = new Schema( }, targetService: { // railway-specific service + // qovery-specific project type: String, default: null, }, targetServiceId: { // railway-specific service + // qovery specific project type: String, default: null, }, @@ -145,6 +148,11 @@ const integrationSchema = new Schema( type: String, default: null, }, + scope: { + // qovery-specific scope + type: String, + default: null + }, integration: { type: String, enum: [ diff --git a/backend/src/models/integration/types.ts b/backend/src/models/integration/types.ts index 898dee04f..5c4387bba 100644 --- a/backend/src/models/integration/types.ts +++ b/backend/src/models/integration/types.ts @@ -1,6 +1,3 @@ - -// TODO: in the future separate metadata -// into distinct types by integration export type Metadata = { secretPrefix?: string; secretSuffix?: string; @@ -8,5 +5,4 @@ export type Metadata = { labelName: string; labelValue: string; } - scope?: "Job" | "Application" | "Container"; } \ No newline at end of file diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index db32eefe1..20c02efc5 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -76,6 +76,7 @@ export const CreateIntegrationV1 = z.object({ owner: z.string().trim().optional(), path: z.string().trim().optional(), region: z.string().trim().optional(), + scope: z.string().trim().optional(), metadata: z.object({ secretPrefix: z.string().optional(), secretSuffix: z.string().optional(), @@ -83,13 +84,6 @@ export const CreateIntegrationV1 = z.object({ labelName: z.string(), labelValue: z.string() }).optional(), - org: z.string().optional(), - orgId: z.string().optional(), - project: z.string().optional(), - projectId: z.string().optional(), - environment: z.string().optional(), - environmentId: z.string().optional(), - scope: z.string().optional() }).optional() }) }); diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 2b31fdb13..dae28c58b 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -52,7 +52,7 @@ const integrationAuthKeys = { }: { integrationAuthId: string; environmentId: string; - scope: "Job" | "Application" | "Container"; + scope: "job" | "application" | "container"; }) => [{ integrationAuthId, environmentId, scope }, "integrationAuthQoveryScopes"] as const, getIntegrationAuthRailwayEnvironments: ({ integrationAuthId, @@ -206,9 +206,9 @@ const fetchIntegrationAuthQoveryScopes = async ({ }: { integrationAuthId: string; environmentId: string; - scope: "Job" | "Application" | "Container"; + scope: "job" | "application" | "container"; }) => { - if (scope === "Application") { + if (scope === "application") { const { data: { apps } } = await apiRequest.get<{ apps: App[] }>( @@ -223,7 +223,7 @@ const fetchIntegrationAuthQoveryScopes = async ({ return apps; } - if (scope === "Container") { + if (scope === "container") { const { data: { containers } } = await apiRequest.get<{ containers: App[] }>( @@ -238,7 +238,7 @@ const fetchIntegrationAuthQoveryScopes = async ({ return containers; } - if (scope === "Job") { + if (scope === "job") { const { data: { jobs } } = await apiRequest.get<{ jobs: App[] }>( @@ -463,7 +463,7 @@ export const useGetIntegrationAuthQoveryScopes = ({ }: { integrationAuthId: string; environmentId: string; - scope: "Job" | "Application" | "Container"; + scope: "job" | "application" | "container"; }) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthQoveryScopes({ diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 61ad89191..0ccc0a3ce 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -40,6 +40,7 @@ export const useCreateIntegration = () => { owner, path, region, + scope, secretPath, metadata }: { @@ -56,16 +57,10 @@ export const useCreateIntegration = () => { owner?: string; path?: string; region?: string; + scope?: string; metadata?: { secretPrefix?: string; secretSuffix?: string; - org?: string; - orgId?: string; - project?: string; - projectId?: string; - environment?: string; - environmentId?: string; - scope?: string; } }) => { const { data: { integration } } = await apiRequest.post("/api/v1/integration", { @@ -80,6 +75,7 @@ export const useCreateIntegration = () => { targetServiceId, owner, path, + scope, region, secretPath, metadata diff --git a/frontend/src/pages/integrations/qovery/create.tsx b/frontend/src/pages/integrations/qovery/create.tsx index 0c6ad1475..ecf46a31f 100644 --- a/frontend/src/pages/integrations/qovery/create.tsx +++ b/frontend/src/pages/integrations/qovery/create.tsx @@ -24,16 +24,38 @@ import { import { useCreateIntegration } from "@app/hooks/api"; -import { useGetIntegrationAuthQoveryEnvironments, useGetIntegrationAuthQoveryOrgs, useGetIntegrationAuthQoveryProjects, useGetIntegrationAuthQoveryScopes } from "@app/hooks/api/integrationAuth/queries"; +import { + useGetIntegrationAuthQoveryEnvironments, + useGetIntegrationAuthQoveryOrgs, + useGetIntegrationAuthQoveryProjects, + useGetIntegrationAuthQoveryScopes +} from "@app/hooks/api/integrationAuth/queries"; import { useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +const qoveryScopes = [ + { label: "Application", value: "application" }, + { label: "Container", value: "container" }, + { label: "Job", value: "job" } +]; + enum TabSections { InfisicalSettings = "infisicalSettings", QoverySettings = "qoverySettings" } +/** + * Follow the logic: + * - Access token + * -> Get organizations (orgId) - select an organization + * -> Get projects belonging to the organization - select project + * -> Get environments belonging to the project - select environmnet + * -> Get qovery scopes / application (this is the application to sync to) + * + * Do we even need to get organizations? + */ + export default function QoveryCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); @@ -43,40 +65,34 @@ export default function QoveryCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); - const [scope, setScope] = useState("Application"); + const [scope, setScope] = useState("application"); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const [secretPath, setSecretPath] = useState("/"); const { data: integrationAuthOrgs } = useGetIntegrationAuthQoveryOrgs((integrationAuthId as string) ?? ""); - const [targetOrg, setTargetOrg] = useState(""); const [targetOrgId, setTargetOrgId] = useState(""); - + const { data: integrationAuthProjects } = useGetIntegrationAuthQoveryProjects({ integrationAuthId: (integrationAuthId as string) ?? "", orgId: targetOrgId }); - const [targetProject, setTargetProject] = useState(""); const [targetProjectId, setTargetProjectId] = useState(""); const { data: integrationAuthEnvironments } = useGetIntegrationAuthQoveryEnvironments({ integrationAuthId: (integrationAuthId as string) ?? "", projectId: targetProjectId }); - const [targetEnvironment, setTargetEnvironment] = useState(""); const [targetEnvironmentId, setTargetEnvironmentId] = useState(""); const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthQoveryScopes({ integrationAuthId: (integrationAuthId as string) ?? "", environmentId: targetEnvironmentId, - scope: (scope as ("Job" | "Application" | "Container")) + scope: (scope as ("job" | "application" | "container")) }); - const [targetApp, setTargetApp] = useState(""); const [targetAppId, setTargetAppId] = useState(""); const [isLoading, setIsLoading] = useState(false); - const scopes = ["Application", "Container", "Job"]; - useEffect(() => { if (workspace) { setSelectedSourceEnvironment(workspace.environments[0].slug); @@ -86,29 +102,19 @@ export default function QoveryCreateIntegrationPage() { useEffect(() => { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { - setTargetApp(integrationAuthApps[0].name); setTargetAppId(String(integrationAuthApps[0].appId)); } else { - setTargetApp("none"); + setTargetAppId("none"); } } }, [integrationAuthApps]); - useEffect(() => { - if (integrationAuthApps) { - if (integrationAuthApps.length > 0) { - setTargetAppId(String(integrationAuthApps.filter(app => app.name === targetApp)[0].appId)); - } - } - }, [targetApp]); - useEffect(() => { if (integrationAuthOrgs) { if (integrationAuthOrgs.length > 0) { - setTargetOrg(integrationAuthOrgs[0].name); setTargetOrgId(String(integrationAuthOrgs[0].orgId)); } else { - setTargetOrg("none"); + setTargetOrgId("none"); } } }, [integrationAuthOrgs]); @@ -116,10 +122,9 @@ export default function QoveryCreateIntegrationPage() { useEffect(() => { if (integrationAuthProjects) { if (integrationAuthProjects.length > 0) { - setTargetProject(integrationAuthProjects[0].name); setTargetProjectId(String(integrationAuthProjects[0].projectId)); } else { - setTargetProject("none"); + setTargetProjectId("none"); } } }, [integrationAuthProjects]); @@ -127,10 +132,9 @@ export default function QoveryCreateIntegrationPage() { useEffect(() => { if (integrationAuthEnvironments) { if (integrationAuthEnvironments.length > 0) { - setTargetEnvironment(integrationAuthEnvironments[0].name); setTargetEnvironmentId(String(integrationAuthEnvironments[0].environmentId)); } else { - setTargetEnvironment("none"); + setTargetEnvironmentId("none"); } } }, [integrationAuthEnvironments]); @@ -141,22 +145,24 @@ export default function QoveryCreateIntegrationPage() { setIsLoading(true); + const targetOrg = integrationAuthOrgs?.find((integrationAuthOrg) => integrationAuthOrg.orgId === targetOrgId)?.name; + const targetProject = integrationAuthProjects?.find((integrationAuthProject) => integrationAuthProject.projectId === targetProjectId)?.name; + const targetEnvironment = integrationAuthEnvironments?.find((integrationAuthEnvironment) => integrationAuthEnvironment.environmentId === targetEnvironmentId)?.name; + const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId)?.name; + await mutateAsync({ integrationAuthId: integrationAuth?._id, isActive: true, app: targetApp, appId: targetAppId, + scope, sourceEnvironment: selectedSourceEnvironment, - secretPath, - metadata: { - scope, - org: targetOrg, - orgId: targetOrgId, - project: targetProject, - projectId: targetProjectId, - environment: targetEnvironment, - environmentId: targetEnvironmentId, - } + targetEnvironment, + targetEnvironmentId, + targetService: targetProject, + targetServiceId: targetProjectId, + owner: targetOrg, + secretPath }); setIsLoading(false); @@ -166,7 +172,7 @@ export default function QoveryCreateIntegrationPage() { console.error(err); } }; - + return integrationAuth && workspace && selectedSourceEnvironment ? ( @@ -255,28 +261,28 @@ export default function QoveryCreateIntegrationPage() { onValueChange={(val) => setScope(val)} className="w-full border border-mineshaft-500" > - {scopes.map((tempScope) => ( + {qoveryScopes.map((qoveryScope) => ( - {tempScope} + {qoveryScope.label} ))} {integrationAuthOrgs && setTargetProject(val)} + value={targetProjectId} + onValueChange={(val) => setTargetProjectId(val)} className="w-full border border-mineshaft-500" isDisabled={integrationAuthProjects.length === 0} > {integrationAuthProjects.length > 0 ? ( integrationAuthProjects.map((integrationAuthProject) => ( {integrationAuthProject.name} @@ -313,16 +319,16 @@ export default function QoveryCreateIntegrationPage() { } {integrationAuthEnvironments && setTargetApp(val)} + value={targetAppId} + onValueChange={(val) => setTargetAppId(val)} className="w-full border border-mineshaft-500" isDisabled={integrationAuthApps.length === 0} > {integrationAuthApps.length > 0 ? ( integrationAuthApps.map((integrationAuthApp) => ( {integrationAuthApp.name} )) ) : ( - No {scope.toLowerCase()}s found + No {scope}s found )} diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 4d441a1fe..04f7e5bd9 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -101,19 +101,19 @@ export const IntegrationsSection = ({
- {integration?.metadata?.org || "-"} + {integration?.owner || "-"}
- {integration?.metadata?.project || "-"} + {integration?.targetService || "-"}
- {integration?.metadata?.environment || "-"} + {integration?.targetEnvironment || "-"}