diff --git a/.env.local b/.env.local index efe07359..df028df8 100644 --- a/.env.local +++ b/.env.local @@ -8,7 +8,7 @@ PLAYGROUND_MOCK_DIR=#./mock PLAYGROUND_FRAME_ANCESTORS=#"'self'" NEXT_PUBLIC_SCULPTOR_VERSION=0.0.0 -NEXT_PUBLIC_PICTOR_VERSION=0.18.0 +NEXT_PUBLIC_PICTOR_VERSION=0.18.1 NEXT_PUBLIC_ALLOWED_REDIRECT_DOMAINS=*.code0.tech,*.codezero.build NEXT_PUBLIC_SUBSCRIPTION_URL=https://codezero.build/subscription diff --git a/package-lock.json b/package-lock.json index ef46e500..71834202 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@apollo/client": "^4.0.9", - "@code0-tech/pictor": "^0.18.0", + "@code0-tech/pictor": "^0.18.1", "@code0-tech/triangulum": "^0.33.0", "@code0-tech/tucana": "^0.0.82", "@codemirror/lang-javascript": "^6.2.5", @@ -1902,9 +1902,9 @@ } }, "node_modules/@code0-tech/pictor": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@code0-tech/pictor/-/pictor-0.18.0.tgz", - "integrity": "sha512-+MEB0XCi7n8zTH3IRCChx088h8Bf12hhy7rcbKmO9iHRucfpenmm/9szLr+mT1PzOoARKwjVPT2TTZbNdJ7+FA==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@code0-tech/pictor/-/pictor-0.18.1.tgz", + "integrity": "sha512-FPUaNtrCuxBe6P65qcAcO12K2IIaBu7eBWGF1rlcQt/UOGc+T7mjMzmLj0ACcDnF7E8a0UUp9oQQB7MiqU7ukg==", "peerDependencies": { "@ark-ui/react": "^5.36.2", "@codemirror/autocomplete": "^6.20.0", diff --git a/package.json b/package.json index 9e4ceec9..6216e1fe 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@apollo/client": "^4.0.9", - "@code0-tech/pictor": "^0.18.0", + "@code0-tech/pictor": "^0.18.1", "@code0-tech/triangulum": "^0.33.0", "@code0-tech/tucana": "^0.0.82", "@codemirror/lang-javascript": "^6.2.5", diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 47dbbdbd..f0dc394f 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -40,6 +40,7 @@ import {DatatypeService} from "@edition/datatype/services/Datatype.service"; import {FlowTypeService} from "@edition/flowtype/services/FlowType.service"; import {ModuleService} from "@edition/module/services/Module.service"; import {AIService, Model} from "@edition/ai/services/AI.service"; +import {UsageEntry, UsageService} from "@edition/usage/services/Usage.service"; interface ApplicationLayoutProps { children: React.ReactNode @@ -76,6 +77,7 @@ const ApplicationLayout: React.FC = ({children, sidebar, const flowtype = usePersistentReactiveArrayService(`dashboard::flowtypes::${currentSession?.id}`, (store) => new FlowTypeService(graphqlClient, store)) const module = usePersistentReactiveArrayService(`dashboard::modules::${currentSession?.id}`, (store) => new ModuleService(graphqlClient, store)) const ai = usePersistentReactiveArrayService(`dashboard::ai::${currentSession?.id}`, (store) => new AIService(graphqlClient, store)) + const usage = usePersistentReactiveArrayService(`dashboard::usage::${currentSession?.id}`, (store) => new UsageService(graphqlClient, store)) const runtimeId = React.useMemo(() => project[1].getById(projectId, {namespaceId})?.primaryRuntime?.id, [projectId, project[0], namespaceId]) @@ -89,10 +91,10 @@ const ApplicationLayout: React.FC = ({children, sidebar, module[1].values({namespaceId, projectId, runtimeId}) }, [runtimeId, namespaceId, projectId, currentSession, flow, functions, datatype, flowtype]) - const isAddLicensePage = pathname === '/licenses/add'; + const isAddLicensePage = pathname === '/licenses/add' return + services={[application, user, organization, member, namespace, runtime, project, role, flow, functions, datatype, flowtype, module, ai, usage]}> @@ -132,12 +134,14 @@ const ApplicationLayout: React.FC = ({children, sidebar, - }> - {sidebar}}> + }> + {sidebar} : undefined}> <> {children} {modal} diff --git a/src/app/(dashboard)/licenses/add/layout.tsx b/src/app/(dashboard)/licenses/add/layout.tsx index 1515ed19..da0f9c7d 100644 --- a/src/app/(dashboard)/licenses/add/layout.tsx +++ b/src/app/(dashboard)/licenses/add/layout.tsx @@ -1,11 +1,13 @@ "use client" import React from "react"; -import {Col, Flex, Spacing, Text} from "@code0-tech/pictor"; +import {Col, Flex, Icon, Spacing, Text} from "@code0-tech/pictor"; export default function LicenseAddLayout({children}: Readonly<{ children: React.ReactNode }>) { return + + diff --git a/src/packages/ce/src/application/views/ApplicationGeneralSettingsView.tsx b/src/packages/ce/src/application/views/ApplicationGeneralSettingsView.tsx index 6baa08d2..8c9a7754 100644 --- a/src/packages/ce/src/application/views/ApplicationGeneralSettingsView.tsx +++ b/src/packages/ce/src/application/views/ApplicationGeneralSettingsView.tsx @@ -4,6 +4,7 @@ import { Button, Card, Flex, + getSize, NumberInput, Spacing, Text, @@ -39,9 +40,6 @@ export const ApplicationGeneralSettingsView: React.FC = () => { [application] ) - // The +/- buttons of NumberInput write to the input element directly without - // triggering a React change event, so the useForm value goes stale. Read the - // value from the element itself on submit instead. const runtimeHeartbeatInputRef = React.useRef(null) const [inputs, validate] = useForm({ @@ -77,82 +75,91 @@ export const ApplicationGeneralSettingsView: React.FC = () => { Versions - + - - + + Sculptor version Version of this application - {process.env.NEXT_PUBLIC_SCULPTOR_VERSION} + + {process.env.NEXT_PUBLIC_SCULPTOR_VERSION ?? "Unknown version"} + - - + + Pictor version Version of the UI component library - {process.env.NEXT_PUBLIC_PICTOR_VERSION} + + {process.env.NEXT_PUBLIC_PICTOR_VERSION ?? "Unknown version"} + - - + + Sagittarius version Version of the backend - {application?.metadata?.version} + + {application?.metadata?.version ?? "Unknown version"} + - Legal URLs - + URLs + - - - Legal notice url - - - + Legal notice url + + General configuration for your Sculptor application, including version information, legal links and runtime behaviour. + + - - - Privacy information url - - - + Privacy information url + + General configuration for your Sculptor application, including version information, legal links and runtime behaviour. + + + - - - Terms and conditions url - - - + Terms and conditions url + + General configuration for your Sculptor application, including version information, legal links and runtime behaviour. + + + Runtimes - + - - - Max heartbeat interval - The maximum amount of minutes a runtime is - shown as connected after the last heartbeat. - - - + Max heartbeat interval + + The maximum amount of minutes a runtime is shown as connected after the last heartbeat. + + + diff --git a/src/packages/ce/src/application/views/ApplicationNavigationView.tsx b/src/packages/ce/src/application/views/ApplicationNavigationView.tsx index 30673636..cc921f9b 100644 --- a/src/packages/ce/src/application/views/ApplicationNavigationView.tsx +++ b/src/packages/ce/src/application/views/ApplicationNavigationView.tsx @@ -4,7 +4,8 @@ import React, {startTransition} from "react"; import { Avatar, Button, - Flex, getSize, + Flex, + getSize, Icon, Menu, MenuContent, @@ -26,27 +27,30 @@ import { IconApps, IconArrowAutofitLeft, IconArrowAutofitLeftFilled, - IconHomeFilled, IconSettingsFilled, IconUser } from "@tabler/icons-react"; import Link from "next/link"; import {UserService} from "@edition/user/services/User.service"; import {useUserSession} from "@edition/user/hooks/User.session.hook"; -import {useRouter} from "next/navigation"; +import {usePathname, useRouter} from "next/navigation"; +import {ApplicationUsageView} from "@edition/application/views/ApplicationUsageView"; export const ApplicationNavigationView: React.FC = () => { - + const pathname = usePathname() + const router = useRouter() const userService = useService(UserService) const userStore = useStore(UserService) - - const router = useRouter() const currentSession = useUserSession() + + const isAddLicensePage = pathname === '/licenses/add'; + const currentUser = React.useMemo( () => userService.getById(currentSession?.user?.id), [userStore, currentSession] ) + const userNamespaceIndex = React.useMemo( () => currentUser?.namespace?.id?.match(/Namespace\/(\d+)$/)?.[1], [currentUser] @@ -83,7 +87,7 @@ export const ApplicationNavigationView: React.FC = () => { - {currentUser?.admin && ( + {currentUser?.admin && !isAddLicensePage && ( @@ -101,8 +105,9 @@ export const ApplicationNavigationView: React.FC = () => { )} + {!isAddLicensePage && } - + {!isAddLicensePage && + ) : ( + + {edited ? : } + {edited ? "Unsaved" : "Synced"} + + ) + } - - Manual layout + + + {edited ? ( + Changes are also saved automatically within 1 minute + ) : ( + Last save {lastSave}.
Everything is + synced.
+ )}
- */} +
-} \ No newline at end of file +} diff --git a/src/packages/ce/src/flow/components/panels/FlowPanelUpdateComponent.tsx b/src/packages/ce/src/flow/components/panels/FlowPanelUpdateComponent.tsx deleted file mode 100644 index e72e5745..00000000 --- a/src/packages/ce/src/flow/components/panels/FlowPanelUpdateComponent.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from "react"; -import {IconCloudCheck, IconCloudUpload} from "@tabler/icons-react"; -import {Panel} from "@xyflow/react"; -import {Flow} from "@code0-tech/sagittarius-graphql-types"; -import { - Badge, - Button, - Text, - Tooltip, - TooltipArrow, - TooltipContent, - TooltipPortal, - TooltipTrigger, - useService, - useStore -} from "@code0-tech/pictor"; -import {FlowService} from "@edition/flow/services/Flow.service"; -import {toast} from "@code0-tech/pictor/dist/components/toast/Toast"; - -export interface FlowPanelUpdateComponentProps { - flowId: Flow['id'] -} - -export const FlowPanelUpdateComponent: React.FC = (props) => { - - const {flowId} = props - - const flowService = useService(FlowService) - const flowStore = useStore(FlowService) - const [loading, startTransition] = React.useTransition() - - const flow = React.useMemo(() => flowService.getById(flowId), [flowId, flowStore]) - - const edited = React.useMemo( - () => !!flow?.editedAt && new Date(flow?.updatedAt ?? Date.now()).getTime() != new Date(flow?.editedAt ?? Date.now()).getTime(), - [flow, flowStore] - ) - const lastSave = React.useMemo( - () => { - return formatTimeAgo(flow?.updatedAt ?? Date.now()) - }, - [flow, flowStore] - ) - - const flowUpdate = React.useCallback(() => { - const flowInput = flowService.getPayloadById(flowId) - if (!flowId) return - - - flowService.flowUpdate({ - flowInput: flowInput!, - flowId: flowId! - }).then(payload => { - if ((payload?.errors?.length ?? 0) <= 0) { - toast({title: "Synced flow", color: "success"}) - } - }) - - }, [flowId, flowService]) - - return - {edited ? ( - - - - - - - - Changes are also saved automatically within 1 minute - - - - ) : ( - - - - - - - - Last save {lastSave}.
Everything is synced.
-
-
-
- )} -
-} - -const formatTimeAgo = (input: Date | number | string) => { - const now = Date.now() - - let pastMs: number - - if (input instanceof Date) { - pastMs = input.getTime() - } else if (typeof input === "number") { - pastMs = input < 1e12 ? input * 1000 : input // sec vs ms - } else { - // string: if numeric => unix, else ISO date string - const n = Number(input) - pastMs = Number.isFinite(n) - ? (n < 1e12 ? n * 1000 : n) - : new Date(input).getTime() - } - - if (!Number.isFinite(pastMs)) return "just now" - - let diff = now - pastMs - if (diff <= 0) return "just now" - - const MIN = 60_000 - const HOUR = 60 * MIN - const DAY = 24 * HOUR - const YEAR = 365 * DAY - - const years = Math.floor(diff / YEAR); - diff %= YEAR - const days = Math.floor(diff / DAY); - diff %= DAY - const hours = Math.floor(diff / HOUR); - diff %= HOUR - const minutes = Math.floor(diff / MIN) - - const parts = [ - years && `${years}y`, - days && `${days}d`, - hours && `${hours}h`, - minutes && `${minutes}m`, - ].filter(Boolean) as string[] - - return parts.length ? `${parts.join(" ")} ago` : "just now" -} \ No newline at end of file diff --git a/src/packages/ce/src/project/views/ProjectSettingsGeneralView.tsx b/src/packages/ce/src/project/views/ProjectSettingsGeneralView.tsx index 7ba0806a..5affbfd6 100644 --- a/src/packages/ce/src/project/views/ProjectSettingsGeneralView.tsx +++ b/src/packages/ce/src/project/views/ProjectSettingsGeneralView.tsx @@ -98,28 +98,37 @@ export const ProjectSettingsGeneralView: React.FC = () => { - - Name - - + Name + + + The display name for this project. It is shown across the platform wherever this project + appears. + + + - - Description - - + Description + + + A short summary of what this project is about. It helps others understand the project’s + purpose at a glance. + + + - - - Slug - - Think of this as your project’s unique nickname in a web address. It helps distinguish this - project from others when using URL-based connections (like REST flows). - - - - + Slug + + + Think of this as your project’s unique nickname in a web address. It helps distinguish this + project from others when using URL-based connections (like REST flows). + + + diff --git a/src/packages/ce/src/usage/components/UsageIndicatorComponent.tsx b/src/packages/ce/src/usage/components/UsageIndicatorComponent.tsx new file mode 100644 index 00000000..833381ca --- /dev/null +++ b/src/packages/ce/src/usage/components/UsageIndicatorComponent.tsx @@ -0,0 +1,181 @@ +"use client" + +import React from "react"; +import { + Button, + Flex, + getSize, + ProgressCircle, + ProgressLinear, + Text, + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger, + useService, + useStore +} from "@code0-tech/pictor"; +import {useParams} from "next/navigation"; +import {addMonths, differenceInMonths, endOfMonth, format, parseISO, startOfMonth} from "date-fns"; +import {UserService} from "@edition/user/services/User.service"; +import {useUserSession} from "@edition/user/hooks/User.session.hook"; +import {LicenseLevel, UsageLevel, UsageLimits, UsageService} from "@edition/usage/services/Usage.service"; + +const NEUTRAL_COLOR = "#ffffff" +const WARNING_COLOR = "#FFBE0B" +const DANGER_COLOR = "#D90429" + +const RANK: Record = {application: 0, namespace: 1, project: 2, flow: 3} +const numberFormat = new Intl.NumberFormat() + +export interface UsageIndicatorComponentProps { + licenseLevel?: LicenseLevel + licenseStartDate?: string + limits?: UsageLimits +} + +export const UsageIndicatorComponent: React.FC = (props) => { + + const {licenseLevel, licenseStartDate, limits = {workflow: undefined, ai: undefined}} = props + + const userService = useService(UserService) + const userStore = useStore(UserService) + const usageService = useService(UsageService) + const usageStore = useStore(UsageService) + + const params = useParams() + const currentSession = useUserSession() + + const namespaceIndex = params.namespaceId as any as number + const projectIndex = params.projectId as any as number + const flowIndex = params.flowId as any as number + const namespaceId = `gid://sagittarius/Namespace/${namespaceIndex}` + const projectId = `gid://sagittarius/NamespaceProject/${projectIndex}` + const flowId = `gid://sagittarius/Flow/${flowIndex}` + + const currentUser = React.useMemo( + () => userService.getById(currentSession?.user?.id), + [userStore, currentSession] + ) + + const overallLevel: UsageLevel = licenseLevel === "namespace" ? "namespace" : "application" + const contextLevel: UsageLevel = flowIndex ? "flow" : projectIndex ? "project" : namespaceIndex ? "namespace" : "application" + const contextLabel = contextLevel === "flow" ? "Flow" : contextLevel === "project" ? "Project" : "Namespace" + + const overallAccessible = overallLevel === "application" ? !!currentUser?.admin : !!namespaceIndex + + const now = new Date() + const licenseStart = licenseStartDate ? parseISO(licenseStartDate) : undefined + const monthsElapsed = licenseStart ? differenceInMonths(now, licenseStart) : 0 + const periodStart = licenseStart ? addMonths(licenseStart, monthsElapsed) : startOfMonth(now) + const periodEnd = licenseStart ? addMonths(licenseStart, monthsElapsed + 1) : addMonths(periodStart, 1) + const afterDate = format(periodStart, "yyyy-MM-dd") + const beforeDate = format(periodEnd, "yyyy-MM-dd") + + const overallUsage = React.useMemo(() => { + if (!overallAccessible) return undefined + return overallLevel === "namespace" + ? usageService.getNamespaceUsage(namespaceId, {afterDate, beforeDate}) + : usageService.getApplicationUsage({afterDate, beforeDate}) + }, [usageStore, overallLevel, overallAccessible, namespaceId, afterDate, beforeDate]) + + const contextUsage = React.useMemo(() => { + if (RANK[contextLevel] <= RANK[overallLevel]) return undefined + if (contextLevel === "flow") return usageService.getFlowUsage(namespaceId, projectId, flowId, {afterDate, beforeDate}) + if (contextLevel === "project") return usageService.getProjectUsage(namespaceId, projectId, {afterDate, beforeDate}) + return usageService.getNamespaceUsage(namespaceId, {afterDate, beforeDate}) + }, [usageStore, contextLevel, overallLevel, namespaceId, projectId, flowId, afterDate, beforeDate]) + + if (!overallAccessible) return null + + const hasContext = !!contextUsage + + const sections = [ + {title: "Workflow usage", overall: overallUsage?.runtimeCount ?? 0, context: contextUsage?.runtimeCount ?? 0, limit: limits.workflow}, + {title: "AI usage", overall: overallUsage?.aiCount ?? 0, context: contextUsage?.aiCount ?? 0, limit: limits.ai} + ].map(section => { + + const bounded = section.limit != null && section.limit > 0 + const exhausted = section.limit != null && section.limit <= 0 + const ratio = bounded ? section.overall / section.limit! : 0 + + return { + ...section, + bounded, + free: bounded ? Math.max(0, section.limit! - section.overall) : 0, + overallFill: section.limit == null || exhausted ? 100 : Math.min(100, Math.round(ratio * 100)), + contextFill: section.limit == null + ? (section.overall > 0 ? Math.min(100, Math.round((section.context / section.overall) * 100)) : 0) + : exhausted ? 100 : Math.min(100, Math.round((section.context / section.limit!) * 100)), + color: bounded + ? (ratio < 0.75 ? NEUTRAL_COLOR : ratio < 0.9 ? WARNING_COLOR : DANGER_COLOR) + : exhausted ? DANGER_COLOR : NEUTRAL_COLOR + } + }) + + return + + + + + + + {sections.map((section, index) => { + + const legend = [ + hasContext && {label: contextLabel, value: section.context, opacity: 1}, + {label: "Overall", value: section.overall, opacity: hasContext ? 0.35 : 1}, + section.bounded && {label: "Free", value: section.free, opacity: 0.12} + ].filter(Boolean) as { label: string, value: number, opacity: number }[] + + return + {index > 0 &&
} + + + + {section.title} + + {section.bounded && ( + + {numberFormat.format(section.overall)} / {numberFormat.format(section.limit!)} + + )} + + + + {legend.map(entry => ( + + +
+ + {entry.label} + + + + {numberFormat.format(entry.value)} + + + ))} + + + + })} + + + + +} diff --git a/src/packages/ce/src/usage/services/Usage.service.ts b/src/packages/ce/src/usage/services/Usage.service.ts new file mode 100644 index 00000000..7fe50ac9 --- /dev/null +++ b/src/packages/ce/src/usage/services/Usage.service.ts @@ -0,0 +1,156 @@ +import {ReactiveArrayService, ReactiveArrayStore} from "@code0-tech/pictor"; +import {Query} from "@code0-tech/sagittarius-graphql-types"; +import {Payload, View} from "@code0-tech/pictor/dist/utils/view"; +import {GraphqlClient} from "@core/util/graphql-client"; +import applicationUsageQuery from "@edition/usage/services/queries/ApplicationUsage.query.graphql"; +import namespaceUsageQuery from "@edition/usage/services/queries/NamespaceUsage.query.graphql"; +import projectUsageQuery from "@edition/usage/services/queries/ProjectUsage.query.graphql"; +import flowUsageQuery from "@edition/usage/services/queries/FlowUsage.query.graphql"; + +export type UsageLevel = "application" | "namespace" | "project" | "flow" + +export type LicenseLevel = "application" | "namespace" + +export interface UsageLimits { + workflow: number | undefined + ai: number | undefined +} + +export interface UsageRange { + afterDate: string + beforeDate: string +} + +export interface UsageEntry extends Payload { + id: string + level: UsageLevel + aiCount: number + aiValue: number + runtimeCount: number + runtimeValue: number +} + +export class UsageService extends ReactiveArrayService { + + protected readonly client: GraphqlClient + + constructor(client: GraphqlClient, store: ReactiveArrayStore>) { + super(store) + this.client = client + } + + getApplicationUsage(range: UsageRange): UsageEntry | undefined { + const cached = this.values().find(entry => entry && entry.id === "application") + if (cached) return cached + + this.client.query({ + query: applicationUsageQuery, + variables: { + afterDate: range.afterDate, + beforeDate: range.beforeDate, + aggregation: "MONTH" + } + }).then(result => { + const application = result.data?.application + if (!application || this.values().find(entry => entry && entry.id === "application")) return + this.add(new View({ + id: "application", + level: "application", + aiCount: (application.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + aiValue: (application.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0), + runtimeCount: (application.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + runtimeValue: (application.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0) + })) + }) + + return undefined + } + + getNamespaceUsage(namespaceId: string, range: UsageRange): UsageEntry | undefined { + const cached = this.values().find(entry => entry && entry.id === namespaceId) + if (cached) return cached + + this.client.query({ + query: namespaceUsageQuery, + variables: { + namespaceId, + afterDate: range.afterDate, + beforeDate: range.beforeDate, + aggregation: "MONTH" + } + }).then(result => { + const namespace = result.data?.namespace + if (!namespace || this.values().find(entry => entry && entry.id === namespaceId)) return + this.add(new View({ + id: namespaceId, + level: "namespace", + aiCount: (namespace.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + aiValue: (namespace.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0), + runtimeCount: (namespace.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + runtimeValue: (namespace.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0) + })) + }) + + return undefined + } + + getProjectUsage(namespaceId: string, projectId: string, range: UsageRange): UsageEntry | undefined { + const cached = this.values().find(entry => entry && entry.id === projectId) + if (cached) return cached + + this.client.query({ + query: projectUsageQuery, + variables: { + namespaceId, + projectId, + afterDate: range.afterDate, + beforeDate: range.beforeDate, + aggregation: "MONTH" + } + }).then(result => { + const project = result.data?.namespace?.project + if (!project || this.values().find(entry => entry && entry.id === projectId)) return + this.add(new View({ + id: projectId, + level: "project", + aiCount: (project.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + aiValue: (project.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0), + runtimeCount: (project.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + runtimeValue: (project.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0) + })) + }) + + return undefined + } + + getFlowUsage(namespaceId: string, projectId: string, flowId: string, range: UsageRange): UsageEntry | undefined { + const cached = this.values().find(entry => entry && entry.id === flowId) + if (cached) return cached + + this.client.query({ + query: flowUsageQuery, + variables: { + namespaceId, + projectId, + flowId, + afterDate: range.afterDate, + beforeDate: range.beforeDate, + aggregation: "MONTH" + } + }).then(result => { + const flow = result.data?.namespace?.project?.flow + if (!flow || this.values().find(entry => entry && entry.id === flowId)) return + this.add(new View({ + id: flowId, + level: "flow", + aiCount: (flow.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + aiValue: (flow.aiUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0), + runtimeCount: (flow.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.usage ?? 0), 0), + runtimeValue: (flow.runtimeUsage ?? []).reduce((total, bucket) => total + (bucket?.value ?? 0), 0) + })) + }) + + return undefined + } + +} diff --git a/src/packages/ce/src/usage/services/fragments/UsageBucket.fragment.graphql b/src/packages/ce/src/usage/services/fragments/UsageBucket.fragment.graphql new file mode 100644 index 00000000..6ad78b7b --- /dev/null +++ b/src/packages/ce/src/usage/services/fragments/UsageBucket.fragment.graphql @@ -0,0 +1,7 @@ +fragment UsageBucket on UsageBucket { + __typename + periodStart + periodEnd + usage + value +} diff --git a/src/packages/ce/src/usage/services/queries/ApplicationUsage.query.graphql b/src/packages/ce/src/usage/services/queries/ApplicationUsage.query.graphql new file mode 100644 index 00000000..f2e77bc2 --- /dev/null +++ b/src/packages/ce/src/usage/services/queries/ApplicationUsage.query.graphql @@ -0,0 +1,17 @@ +#import "@edition/usage/services/fragments/UsageBucket.fragment.graphql" + +query ApplicationUsage( + $afterDate: ISO8601Date!, + $beforeDate: ISO8601Date!, + $aggregation: UsageAggregation +) { + application { + __typename + aiUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + runtimeUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + } +} diff --git a/src/packages/ce/src/usage/services/queries/FlowUsage.query.graphql b/src/packages/ce/src/usage/services/queries/FlowUsage.query.graphql new file mode 100644 index 00000000..629e27fa --- /dev/null +++ b/src/packages/ce/src/usage/services/queries/FlowUsage.query.graphql @@ -0,0 +1,29 @@ +#import "@edition/usage/services/fragments/UsageBucket.fragment.graphql" + +query FlowUsage( + $namespaceId: NamespaceID!, + $projectId: NamespaceProjectID!, + $flowId: FlowID!, + $afterDate: ISO8601Date!, + $beforeDate: ISO8601Date!, + $aggregation: UsageAggregation +) { + namespace(id: $namespaceId) { + __typename + id + project(id: $projectId) { + __typename + id + flow(id: $flowId) { + __typename + id + aiUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + runtimeUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + } + } + } +} diff --git a/src/packages/ce/src/usage/services/queries/NamespaceUsage.query.graphql b/src/packages/ce/src/usage/services/queries/NamespaceUsage.query.graphql new file mode 100644 index 00000000..441157bd --- /dev/null +++ b/src/packages/ce/src/usage/services/queries/NamespaceUsage.query.graphql @@ -0,0 +1,19 @@ +#import "@edition/usage/services/fragments/UsageBucket.fragment.graphql" + +query NamespaceUsage( + $namespaceId: NamespaceID!, + $afterDate: ISO8601Date!, + $beforeDate: ISO8601Date!, + $aggregation: UsageAggregation +) { + namespace(id: $namespaceId) { + __typename + id + aiUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + runtimeUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + } +} diff --git a/src/packages/ce/src/usage/services/queries/ProjectUsage.query.graphql b/src/packages/ce/src/usage/services/queries/ProjectUsage.query.graphql new file mode 100644 index 00000000..a851d6fc --- /dev/null +++ b/src/packages/ce/src/usage/services/queries/ProjectUsage.query.graphql @@ -0,0 +1,24 @@ +#import "@edition/usage/services/fragments/UsageBucket.fragment.graphql" + +query ProjectUsage( + $namespaceId: NamespaceID!, + $projectId: NamespaceProjectID!, + $afterDate: ISO8601Date!, + $beforeDate: ISO8601Date!, + $aggregation: UsageAggregation +) { + namespace(id: $namespaceId) { + __typename + id + project(id: $projectId) { + __typename + id + aiUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + runtimeUsage(afterDate: $afterDate, beforeDate: $beforeDate, aggregation: $aggregation) { + ...UsageBucket + } + } + } +} diff --git a/src/packages/cloud/src/application/views/ApplicationUsageView.tsx b/src/packages/cloud/src/application/views/ApplicationUsageView.tsx new file mode 100644 index 00000000..9b435189 --- /dev/null +++ b/src/packages/cloud/src/application/views/ApplicationUsageView.tsx @@ -0,0 +1,41 @@ +"use client" + +import React from "react"; +import {useService, useStore} from "@code0-tech/pictor"; +import {useParams} from "next/navigation"; +import {isFuture, isPast} from "date-fns"; +import {Namespace} from "@code0-tech/sagittarius-graphql-types"; +import {NamespaceService} from "@edition/namespace/services/Namespace.service"; +import {UsageIndicatorComponent} from "@edition/usage/components/UsageIndicatorComponent"; + +const FREE_WORKFLOW_LIMIT = 50 +const FREE_AI_LIMIT = 25000 + +export const ApplicationUsageView: React.FC = () => { + + const params = useParams() + const namespaceService = useService(NamespaceService) + const namespaceStore = useStore(NamespaceService) + + const namespaceIndex = params.namespaceId as any as number + const namespaceId: Namespace["id"] = `gid://sagittarius/Namespace/${namespaceIndex}` + + const namespace = React.useMemo( + () => namespaceIndex ? namespaceService.getById(namespaceId) : undefined, + [namespaceStore, namespaceIndex] + ) + + const activeLicense = namespace?.licenses?.nodes?.find(license => + !!license?.startDate && !!license?.endDate && isPast(license.startDate) && isFuture(license.endDate) + ) + + const limits = !namespaceIndex || activeLicense + ? {workflow: undefined, ai: undefined} + : {workflow: FREE_WORKFLOW_LIMIT, ai: FREE_AI_LIMIT} + + return +} diff --git a/src/packages/ee/src/application/components/ApplicationMiddlewareComponent.tsx b/src/packages/ee/src/application/components/ApplicationMiddlewareComponent.tsx index 42b2457f..762b97a9 100644 --- a/src/packages/ee/src/application/components/ApplicationMiddlewareComponent.tsx +++ b/src/packages/ee/src/application/components/ApplicationMiddlewareComponent.tsx @@ -19,12 +19,7 @@ export const ApplicationMiddlewareComponent: React.FC userService.getById(currentSession?.user?.id), @@ -36,6 +31,11 @@ export const ApplicationMiddlewareComponent: React.FC { + + const applicationService = useService(ApplicationService) + const applicationStore = useStore(ApplicationService) + + const application = React.useMemo(() => applicationService.get(), [applicationStore]) + const license = application?.currentLicense + + const hasActiveLicense = !!license?.startDate && !!license?.endDate + && isPast(license.startDate) && isFuture(license.endDate) + + return +} diff --git a/src/packages/ee/src/license/pages/LicenseAddPage.tsx b/src/packages/ee/src/license/pages/LicenseAddPage.tsx index d788790c..2a2e8e04 100644 --- a/src/packages/ee/src/license/pages/LicenseAddPage.tsx +++ b/src/packages/ee/src/license/pages/LicenseAddPage.tsx @@ -15,16 +15,17 @@ import { FileInputItemPreview, FileInputItemSizeText, FileInputTrigger, - Flex, + Flex, getSize, hashToColor, Spacing, Text, useForm, useService } from "@code0-tech/pictor"; -import {IconFileInfoFilled, IconX} from "@tabler/icons-react"; +import {IconCloudUpload, IconFileInfoFilled, IconX} from "@tabler/icons-react"; import {FileUploadFileChangeDetails} from "@ark-ui/react"; import {ApplicationService} from "@ee-internal/application/services/Application.service"; +import Link from "next/link"; import {useRouter} from "next/navigation"; import {toast} from "@code0-tech/pictor/dist/components/toast/Toast"; @@ -33,10 +34,13 @@ export const LicenseAddPage: React.FC = () => { const applicationService = useService(ApplicationService) const router = useRouter() - const [inputs, validate, values] = useForm<{ file: FileUploadFileChangeDetails | undefined }>({ - initialValues: { - file: undefined - }, + const initialValues = React.useMemo(() => ({ + file: undefined + }), []) + + const [inputs, validate] = useForm<{ file: FileUploadFileChangeDetails | undefined }>({ + initialValues, + useInitialValidation: false, validate: { file: (value) => { if (!value || value.acceptedFiles.length <= 0) return "Please upload a license file" @@ -59,30 +63,33 @@ export const LicenseAddPage: React.FC = () => { } }) - React.useEffect(() => { - validate("file") - }, [values]) - return <> Add a license - Build high-class workflows, endpoints and software without coding + Upload your license file to unlock the Enterprise Edition features. {/*@ts-ignore*/} - - + + style={{textAlign: "center", flexDirection: "column", gap: "1rem", padding: "1rem 0"}}> + + + Drag license file or @@ -93,17 +100,17 @@ export const LicenseAddPage: React.FC = () => { to upload - + Only .czlc files are accepted. - + {({acceptedFiles}) => acceptedFiles?.map((file) => ( - + { @@ -113,7 +120,7 @@ export const LicenseAddPage: React.FC = () => { ) } - + @@ -123,7 +130,7 @@ export const LicenseAddPage: React.FC = () => { - @@ -139,5 +146,22 @@ export const LicenseAddPage: React.FC = () => { + + + Already bought a license? + + + Download it here + + + + + Don't have a license yet? + + + Buy a license + + + } \ No newline at end of file