diff --git a/apps/jobs/package.json b/apps/jobs/package.json index 9a7722dd..be1097f9 100644 --- a/apps/jobs/package.json +++ b/apps/jobs/package.json @@ -18,12 +18,15 @@ "@ctrlplane/logger": "workspace:*", "@ctrlplane/validators": "workspace:*", "cron": "^3.1.7", + "lodash": "^4.17.21", + "ts-is-present": "^1.2.2", "zod": "catalog:" }, "devDependencies": { "@ctrlplane/eslint-config": "workspace:^", "@ctrlplane/prettier-config": "workspace:^", "@ctrlplane/tsconfig": "workspace:*", + "@types/lodash": "^4.17.5", "eslint": "catalog:", "prettier": "catalog:", "tsx": "catalog:", diff --git a/apps/jobs/src/expired-env-checker/index.ts b/apps/jobs/src/expired-env-checker/index.ts new file mode 100644 index 00000000..21665886 --- /dev/null +++ b/apps/jobs/src/expired-env-checker/index.ts @@ -0,0 +1,56 @@ +import _ from "lodash"; +import { isPresent } from "ts-is-present"; + +import { eq, inArray, lte } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { logger } from "@ctrlplane/logger"; + +type QueryRow = { + environment: SCHEMA.Environment; + deployment: SCHEMA.Deployment; +}; + +const groupByEnvironment = (rows: QueryRow[]) => + _.chain(rows) + .groupBy((e) => e.environment.id) + .map((env) => ({ + ...env[0]!.environment, + deployments: env.map((e) => e.deployment), + })) + .value(); + +export const run = async () => { + const expiredEnvironments = await db + .select() + .from(SCHEMA.environment) + .innerJoin( + SCHEMA.deployment, + eq(SCHEMA.deployment.systemId, SCHEMA.environment.systemId), + ) + .where(lte(SCHEMA.environment.expiresAt, new Date())) + .then(groupByEnvironment); + if (expiredEnvironments.length === 0) return; + + const targetPromises = expiredEnvironments + .filter((env) => isPresent(env.targetFilter)) + .map(async (env) => { + const targets = await db + .select() + .from(SCHEMA.target) + .where(SCHEMA.targetMatchesMetadata(db, env.targetFilter)); + + return { environmentId: env.id, targets }; + }); + const associatedTargets = await Promise.all(targetPromises); + + for (const { environmentId, targets } of associatedTargets) + logger.info( + `[${targets.length}] targets are associated with expired environment [${environmentId}]`, + ); + + const envIds = expiredEnvironments.map((env) => env.id); + await db + .delete(SCHEMA.environment) + .where(inArray(SCHEMA.environment.id, envIds)); +}; diff --git a/apps/jobs/src/index.ts b/apps/jobs/src/index.ts index af3ae115..cb2c17d9 100644 --- a/apps/jobs/src/index.ts +++ b/apps/jobs/src/index.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { logger } from "@ctrlplane/logger"; +import { run as expiredEnvChecker } from "./expired-env-checker/index.js"; import { run as jobPolicyChecker } from "./policy-checker/index.js"; const jobs: Record Promise; schedule: string }> = { @@ -22,6 +23,10 @@ const jobs: Record Promise; schedule: string }> = { run: jobPolicyChecker, schedule: "* * * * *", // Default: Every minute }, + "expired-env-checker": { + run: expiredEnvChecker, + schedule: "* * * * *", // Default: Every minute + }, }; const jobSchema = z.object({ diff --git a/apps/jobs/src/policy-checker/index.ts b/apps/jobs/src/policy-checker/index.ts index 5aa67af3..3b1647e9 100644 --- a/apps/jobs/src/policy-checker/index.ts +++ b/apps/jobs/src/policy-checker/index.ts @@ -6,6 +6,7 @@ import { dispatchReleaseJobTriggers, isPassingAllPolicies, } from "@ctrlplane/job-dispatch"; +import { logger } from "@ctrlplane/logger"; import { JobStatus } from "@ctrlplane/validators/jobs"; export const run = async () => { @@ -44,7 +45,7 @@ export const run = async () => { .then((rows) => rows.map((row) => row.release_job_trigger)); if (releaseJobTriggers.length === 0) return; - console.log( + logger.info( `Found [${releaseJobTriggers.length}] release job triggers to dispatch`, ); diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx index 4f04907b..1f60ebdf 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx @@ -50,6 +50,7 @@ export const EnvironmentDrawer: React.FC = () => { const environmentQ = api.environment.byId.useQuery(environmentId ?? "", { enabled: isOpen, }); + const environmentQError = environmentQ.error; const environment = environmentQ.data; const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); @@ -75,17 +76,24 @@ export const EnvironmentDrawer: React.FC = () => { showBar={false} className="left-auto right-0 top-0 mt-0 h-screen w-2/3 max-w-6xl overflow-auto rounded-none focus-visible:outline-none" > - -
- + +
+
+ +
+ {environment?.name} + {environment != null && ( + + + + )}
- {environment?.name} - {environment != null && ( - - - + {environmentQError != null && ( +
+ {environmentQError.message} +
)}
diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/Overview.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/Overview.tsx index ffaed27b..51d22c31 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/Overview.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/Overview.tsx @@ -1,13 +1,16 @@ import type * as SCHEMA from "@ctrlplane/db/schema"; +import { IconX } from "@tabler/icons-react"; import { z } from "zod"; import { Button } from "@ctrlplane/ui/button"; +import { DateTimePicker } from "@ctrlplane/ui/datetime-picker"; import { Form, FormControl, FormField, FormItem, FormLabel, + FormMessage, useForm, } from "@ctrlplane/ui/form"; import { Input } from "@ctrlplane/ui/input"; @@ -18,14 +21,31 @@ import { api } from "~/trpc/react"; const schema = z.object({ name: z.string().min(1).max(100), description: z.string().max(1000).nullable(), + expiresAt: z + .date() + .min(new Date(), "Expires at must be in the future") + .optional(), }); type OverviewProps = { environment: SCHEMA.Environment; }; +const isUsing12HourClock = (): boolean => { + const date = new Date(); + const options: Intl.DateTimeFormatOptions = { + hour: "numeric", + }; + const formattedTime = new Intl.DateTimeFormat(undefined, options).format( + date, + ); + return formattedTime.includes("AM") || formattedTime.includes("PM"); +}; + export const Overview: React.FC = ({ environment }) => { - const form = useForm({ schema, defaultValues: environment }); + const expiresAt = environment.expiresAt ?? undefined; + const defaultValues = { ...environment, expiresAt }; + const form = useForm({ schema, defaultValues }); const update = api.environment.update.useMutation(); const envOverride = api.job.trigger.create.byEnvId.useMutation(); @@ -71,6 +91,35 @@ export const Overview: React.FC = ({ environment }) => { )} /> + ( + + Expires at + +
+ + +
+
+ +
+ )} + />
+ + + handleSelect(d)} + onMonthChange={handleSelect} + yearRange={yearRange} + locale={locale} + {...props} + /> + {granularity !== "day" && ( +
+ +
+ )} +
+ + ); + }, +); + +DateTimePicker.displayName = "DateTimePicker"; + +export { DateTimePicker, TimePickerInput, TimePicker }; +export type { TimePickerType, DateTimePickerProps, DateTimePickerRef }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76b3e1aa..7cde462b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,12 @@ importers: cron: specifier: ^3.1.7 version: 3.1.7 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + ts-is-present: + specifier: ^1.2.2 + version: 1.2.2 zod: specifier: 'catalog:' version: 3.23.8 @@ -314,6 +320,9 @@ importers: '@ctrlplane/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/lodash': + specifier: ^4.17.5 + version: 4.17.12 eslint: specifier: 'catalog:' version: 9.11.1(jiti@2.3.3) @@ -1381,7 +1390,7 @@ importers: specifier: ^1.0.3 version: 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.0.2 + specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.10)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.0 @@ -1401,6 +1410,9 @@ importers: cmdk: specifier: ^1.0.0 version: 1.0.0(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 lucide-react: specifier: ^0.441.0 version: 0.441.0(react@18.3.1) @@ -1410,6 +1422,9 @@ importers: react-aria: specifier: ^3.33.1 version: 3.35.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-day-picker: + specifier: ^9.2.1 + version: 9.2.1(react@18.3.1) react-hook-form: specifier: ^7.51.4 version: 7.53.1(react@18.3.1) @@ -1504,7 +1519,7 @@ importers: version: 1.13.4(eslint@9.11.1(jiti@2.3.3)) eslint-plugin-import: specifier: ^2.29.1 - version: 2.31.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@2.3.3))(typescript@5.6.3))(eslint@9.11.1(jiti@2.3.3)) + version: 2.31.0(eslint@9.11.1(jiti@2.3.3)) eslint-plugin-jsx-a11y: specifier: ^6.9.0 version: 6.10.2(eslint@9.11.1(jiti@2.3.3)) @@ -1775,6 +1790,9 @@ packages: '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + '@date-fns/tz@1.1.2': + resolution: {integrity: sha512-Xmg2cPmOPQieCLAdf62KtFPU9y7wbQDq1OAzrs/bEQFvhtCPXDiks1CHDE/sTXReRfh/MICVkw/vY6OANHUGiA==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -6925,6 +6943,9 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -10362,6 +10383,12 @@ packages: peerDependencies: react: ^15.3.0 || 16 || 17 || 18 + react-day-picker@9.2.1: + resolution: {integrity: sha512-rCoK4oJi9NBXt1nNdQFSa7gBG+hWsqVCtoLFLxvMzkVxDp+rSqsuUQ0LccJyLigwp/hX8XnAokTsT03+5lbjyA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-debounce-input@3.3.0: resolution: {integrity: sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==} peerDependencies: @@ -12805,6 +12832,8 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@date-fns/tz@1.1.2': {} + '@discoveryjs/json-ext@0.5.7': {} '@drizzle-team/brocli@0.10.1': {} @@ -18789,6 +18818,8 @@ snapshots: date-fns@3.6.0: {} + date-fns@4.1.0: {} + dayjs@1.11.13: {} dead-or-alive@1.0.4: @@ -19314,17 +19345,16 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.11.1(jiti@2.3.3)): + eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.11.1(jiti@2.3.3)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.7.0(eslint@9.11.1(jiti@2.3.3))(typescript@5.6.3) eslint: 9.11.1(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@2.3.3))(typescript@5.6.3))(eslint@9.11.1(jiti@2.3.3)): + eslint-plugin-import@2.31.0(eslint@9.11.1(jiti@2.3.3)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -19335,7 +19365,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.11.1(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.11.1(jiti@2.3.3)) + eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.11.1(jiti@2.3.3)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -19346,8 +19376,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.7.0(eslint@9.11.1(jiti@2.3.3))(typescript@5.6.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -23070,6 +23098,12 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 + react-day-picker@9.2.1(react@18.3.1): + dependencies: + '@date-fns/tz': 1.1.2 + date-fns: 4.1.0 + react: 18.3.1 + react-debounce-input@3.3.0(react@18.3.1): dependencies: lodash.debounce: 4.0.8