diff --git a/internal/server/api/v1/stream/events.go b/internal/server/api/v1/stream/events.go index fc470f15..920f4a38 100644 --- a/internal/server/api/v1/stream/events.go +++ b/internal/server/api/v1/stream/events.go @@ -2,14 +2,11 @@ // Use of this source code is governed by the Gitploy Non-Commercial License // that can be found in the LICENSE file. -// +build !oss +//go:build !oss package stream import ( - "context" - "fmt" - "math/rand" "time" "github.com/gin-contrib/sse" @@ -19,7 +16,6 @@ import ( gb "github.com/gitploy-io/gitploy/internal/server/global" "github.com/gitploy-io/gitploy/model/ent" "github.com/gitploy-io/gitploy/model/ent/event" - "github.com/gitploy-io/gitploy/pkg/e" ) // GetEvents streams events of deployment, or review. @@ -29,30 +25,56 @@ func (s *Stream) GetEvents(c *gin.Context) { v, _ := c.Get(gb.KeyUser) u, _ := v.(*ent.User) - debugID := randstr() - - events := make(chan *ent.Event, 10) + events := make(chan *sse.Event, 10) // Subscribe events // it'll unsubscribe after the connection is closed. sub := func(e *ent.Event) { - - // Deleted type is always propagated to all. - if e.Type == event.TypeDeleted { - events <- e - return - } - - if ok, err := s.hasPermForEvent(ctx, u, e); err != nil { - s.log.Error("It has failed to check the perm.", zap.Error(err)) - return - } else if !ok { - s.log.Debug("Skip the event. The user has not the perm.") - return + switch e.Kind { + case event.KindDeployment: + d, err := s.i.FindDeploymentByID(ctx, e.DeploymentID) + if err != nil { + s.log.Error("Failed to find the deployment.", zap.Error(err)) + return + } + + if _, err := s.i.FindPermOfRepo(ctx, d.Edges.Repo, u); err != nil { + s.log.Debug("Skip the event. The permission is denied.") + return + } + + s.log.Debug("Dispatch a deployment event.", zap.Int("id", d.ID)) + events <- &sse.Event{ + Event: "deployment", + Data: d, + } + + case event.KindReview: + r, err := s.i.FindReviewByID(ctx, e.ReviewID) + if err != nil { + s.log.Error("Failed to find the review.", zap.Error(err)) + return + } + + d, err := s.i.FindDeploymentByID(ctx, r.DeploymentID) + if err != nil { + s.log.Error("Failed to find the deployment.", zap.Error(err)) + return + } + + if _, err := s.i.FindPermOfRepo(ctx, d.Edges.Repo, u); err != nil { + s.log.Debug("Skip the event. The permission is denied.") + return + } + + s.log.Debug("Dispatch a review event.", zap.Int("id", r.ID)) + events <- &sse.Event{ + Event: "review", + Data: r, + } } - - events <- e } + if err := s.i.SubscribeEvent(sub); err != nil { s.log.Check(gb.GetZapLogLevel(err), "Failed to subscribe notification events").Write(zap.Error(err)) gb.ResponseWithError(c, err) @@ -83,62 +105,8 @@ L: }) w.Flush() case e := <-events: - c.Render(-1, sse.Event{ - Event: "event", - Data: e, - }) + c.Render(-1, e) w.Flush() - s.log.Debug("server sent event.", zap.Int("event_id", e.ID), zap.String("debug_id", debugID)) - } - } -} - -// hasPermForEvent checks the user has permission for the event. -func (s *Stream) hasPermForEvent(ctx context.Context, u *ent.User, evt *ent.Event) (bool, error) { - if evt.Kind == event.KindDeployment { - d, err := s.i.FindDeploymentByID(ctx, evt.DeploymentID) - if err != nil { - return false, err - } - - if _, err = s.i.FindPermOfRepo(ctx, d.Edges.Repo, u); e.HasErrorCode(err, e.ErrorCodeEntityNotFound) { - return false, nil - } else if err != nil { - return false, err } - - return true, nil - } - - if evt.Kind == event.KindReview { - rv, err := s.i.FindReviewByID(ctx, evt.ReviewID) - if err != nil { - return false, err - } - - d, err := s.i.FindDeploymentByID(ctx, rv.DeploymentID) - if err != nil { - return false, err - } - - if _, err = s.i.FindPermOfRepo(ctx, d.Edges.Repo, u); e.HasErrorCode(err, e.ErrorCodeEntityNotFound) { - return false, nil - } else if err != nil { - return false, err - } - - return true, nil - } - - return false, fmt.Errorf("The type of event is not \"deployment\" or \"review\".") -} - -func randstr() string { - var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - - b := make([]rune, 4) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] } - return string(b) } diff --git a/openapi/v1.yaml b/openapi/v1.yaml index 5476a800..786ca694 100644 --- a/openapi/v1.yaml +++ b/openapi/v1.yaml @@ -1436,41 +1436,13 @@ paths: type: integer event: type: string + enum: + - deployment + - review data: - type: object - format: json - required: - - id - - kind - - type - - created_at - - edges - properties: - id: - type: integer - kind: - type: string - enum: - - deployment - - review - type: - type: string - enum: - - created - - updated - - deleted - created_at: - type: string - deleted_id: - type: integer - description: The ID of deleted resource. - edges: - type: object - properties: - deployment: - $ref: '#/components/schemas/Deployment' - review: - $ref: '#/components/schemas/Review' + oneOf: + - $ref: '#/components/schemas/Deployment' + - $ref: '#/components/schemas/Review' /sync: post: tags: diff --git a/ui/src/apis/events.ts b/ui/src/apis/events.ts index 4737c8c8..600ed2ba 100644 --- a/ui/src/apis/events.ts +++ b/ui/src/apis/events.ts @@ -1,80 +1,36 @@ import { instance } from './setting' -import { DeploymentData, mapDataToDeployment } from "./deployment" -import { ReviewData, mapDataToReview } from "./review" -import { Deployment, Review, Event, EventKindEnum, EventTypeEnum } from "../models" +import { mapDataToDeployment } from "./deployment" +import { mapDataToReview } from "./review" +import { Deployment, Review } from "../models" -interface EventData { - id: number - kind: string - type: string - deleted_id: number - edges: { - deployment?: DeploymentData - review?: ReviewData - } -} - -const mapDataToEvent = (data: EventData): Event => { - let kind: EventKindEnum - let type: EventTypeEnum - let deployment: Deployment | undefined - let review: Review | undefined - - switch (data.kind) { - case "deployment": - kind = EventKindEnum.Deployment - break - case "review": - kind = EventKindEnum.Review - break - default: - kind = EventKindEnum.Deployment - } - switch (data.type) { - case "created": - type = EventTypeEnum.Created - break - case "updated": - type = EventTypeEnum.Updated - break - case "deleted": - type = EventTypeEnum.Deleted - break - default: - type = EventTypeEnum.Created - } +export const subscribeDeploymentEvents = (cb: (deployment: Deployment) => void): EventSource => { + const sse = new EventSource(`${instance}/api/v1/stream/events`, { + withCredentials: true, + }) - if (data.edges.deployment) { - deployment = mapDataToDeployment(data.edges.deployment) - } + sse.addEventListener("deployment", (e: any) => { + const data = JSON.parse(e.data) + const deployment = mapDataToDeployment(data) - if (data.edges.review) { - review = mapDataToReview(data.edges.review) - } + cb(deployment) + }) - return { - id: data.id, - kind, - type, - deletedId: data.deleted_id, - deployment, - review - } + return sse } -export const subscribeEvents = (cb: (event: Event) => void): EventSource => { +export const subscribeReviewEvents = (cb: (review: Review) => void): EventSource => { const sse = new EventSource(`${instance}/api/v1/stream/events`, { withCredentials: true, }) - sse.addEventListener("event", (e: any) => { + sse.addEventListener("review", (e: any) => { const data = JSON.parse(e.data) - const event = mapDataToEvent(data) + const review = mapDataToReview(data) - cb(event) + cb(review) }) return sse -} \ No newline at end of file +} diff --git a/ui/src/apis/index.ts b/ui/src/apis/index.ts index 06827353..f5b8fcba 100644 --- a/ui/src/apis/index.ts +++ b/ui/src/apis/index.ts @@ -1,5 +1,7 @@ -import { sync } from "./sync" -import { +export { + sync +} from "./sync" +export { listRepos, getRepo, updateRepo, @@ -8,8 +10,10 @@ import { lockRepo, unlockRepo, } from "./repo" -import { listPerms } from "./perm" -import { +export { + listPerms +} from "./perm" +export { searchDeployments, listDeployments, getDeployment, @@ -18,68 +22,49 @@ import { rollbackDeployment, listDeploymentChanges } from './deployment' -import { getConfig } from './config' -import { listCommits, getCommit, listStatuses } from './commit' -import { listBranches, getBranch } from './branch' -import { listTags, getTag } from './tag' -import { listUsers, updateUser, deleteUser, getMe, getRateLimit } from "./user" -import { checkSlack } from "./chat" -import { +export { + getConfig +} from './config' +export { + listCommits, + getCommit, + listStatuses +} from './commit' +export { + listBranches, + getBranch +} from './branch' +export { + listTags, + getTag +} from './tag' +export { + listUsers, + updateUser, + deleteUser, + getMe, + getRateLimit +} from "./user" +export { + checkSlack +} from "./chat" +export { searchReviews, listReviews, getUserReview, approveReview, rejectReview, } from "./review" -import { +export { listLocks, lock, unlock, updateLock } from "./lock" -import { getLicense } from "./license" -import { subscribeEvents } from "./events" - -export { - sync, - listRepos, - getRepo, - updateRepo, - activateRepo, - deactivateRepo, - lockRepo, - unlockRepo, - listPerms, - searchDeployments, - listDeployments, - getDeployment, - createDeployment, - createRemoteDeployment, - rollbackDeployment, - listDeploymentChanges, - getConfig, - listCommits, - getCommit, - listStatuses, - listBranches, - getBranch, - listTags, - getTag, - listUsers, - updateUser, - deleteUser, - getMe, - getRateLimit, - checkSlack, - searchReviews, - listReviews, - getUserReview, - approveReview, - rejectReview, - listLocks, - lock, - unlock, - updateLock, - getLicense, - subscribeEvents -} \ No newline at end of file +export { + getLicense +} from "./license" +export { + subscribeDeploymentEvents, + subscribeReviewEvents, +} from "./events" diff --git a/ui/src/redux/deployment.tsx b/ui/src/redux/deployment.tsx index 039d3e65..593dd932 100644 --- a/ui/src/redux/deployment.tsx +++ b/ui/src/redux/deployment.tsx @@ -5,7 +5,6 @@ import { Deployment, Commit, Review, - Event, RequestStatus, HttpNotFoundError, HttpForbiddenError, @@ -179,18 +178,14 @@ export const deploymentSlice = createSlice({ setDisplay: (state, action: PayloadAction) => { state.display = action.payload }, - handleDeploymentEvent: (state, action: PayloadAction) => { - const event = action.payload - - if (event.deployment?.id !== state.deployment?.id) { - return + handleDeploymentEvent: (state, action: PayloadAction) => { + if (action.payload.id === state.deployment?.id) { + state.deployment = action.payload } - - state.deployment = event.deployment }, - handleReviewEvent: (state, action: PayloadAction) => { + handleReviewEvent: (state, action: PayloadAction) => { state.reviews = state.reviews.map((review) => { - return (review.id === action.payload.review?.id)? action.payload.review : review + return (action.payload.id === review.id)? action.payload : review }) } }, diff --git a/ui/src/redux/home.ts b/ui/src/redux/home.ts index 09cf2587..0c1b9f92 100644 --- a/ui/src/redux/home.ts +++ b/ui/src/redux/home.ts @@ -3,8 +3,6 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' import { Repo, RequestStatus, - Event, - EventTypeEnum } from '../models' import * as apis from '../apis' @@ -73,29 +71,6 @@ export const homeSlice = createSlice({ decreasePage: (state) => { state.page = state.page - 1 }, - handleDeploymentEvent: (state, action: PayloadAction) => { - const event = action.payload - - state.repos = state.repos.map((repo) => { - if (event.deployment?.repo?.id !== repo.id) { - return repo - } - - if (!repo.deployments) { - repo.deployments = [] - } - - if (event.type === EventTypeEnum.Created) { - repo.deployments.unshift(event.deployment) - return repo - } - - repo.deployments = repo.deployments.map((deployment) => { - return (event.deployment?.id === deployment.id )? event.deployment : deployment - }) - return repo - }) - }, }, extraReducers: builder => { builder diff --git a/ui/src/redux/main.ts b/ui/src/redux/main.ts index f5d10a40..3022cc3f 100644 --- a/ui/src/redux/main.ts +++ b/ui/src/redux/main.ts @@ -6,13 +6,11 @@ import { Deployment, DeploymentStatusEnum, Review, - Event, - EventKindEnum, - EventTypeEnum, HttpInternalServerError, HttpUnauthorizedError, HttpPaymentRequiredError, License, + ReviewStatusEnum, } from "../models" import { getMe, @@ -139,32 +137,22 @@ const notify = (title: string, options?: NotificationOptions) => { /** * The browser notifies only the user who triggers the deployment. */ -export const notifyDeploymentEvent = createAsyncThunk( +export const notifyDeploymentEvent = createAsyncThunk( "main/notifyDeploymentEvent", - async (event, { getState }) => { - const { user } = getState().main - - if (!(event.kind === EventKindEnum.Deployment && event.deployment)) { - return - } - - if (event.deployment.deployer?.id !== user?.id) { - return - } - - if (event.type === EventTypeEnum.Created) { - notify(`New Deployment #${event.deployment.number}`, { + async (deployment) => { + if (deployment.status === DeploymentStatusEnum.Created) { + notify(`New Deployment #${deployment.number}`, { icon: "/logo192.png", - body: `Start to deploy ${getShortRef(event.deployment)} to the ${event.deployment.env} environment of ${event.deployment.repo?.namespace}/${event.deployment.repo?.name}.`, - tag: String(event.id), + body: `Start to deploy ${getShortRef(deployment)} to the ${deployment.env} environment of ${deployment.repo?.namespace}/${deployment.repo?.name}.`, + tag: String(deployment.id), }) return } - notify(`Deployment Updated #${event.deployment.number}`, { + notify(`Deployment Updated #${deployment.number}`, { icon: "/logo192.png", - body: `The deployment ${event.deployment.number} of ${event.deployment.repo?.namespace}/${event.deployment.repo?.name} is updated ${event.deployment.status}.`, - tag: String(event.id), + body: `The deployment ${deployment.number} of ${deployment.repo?.namespace}/${deployment.repo?.name} is updated ${deployment.status}.`, + tag: String(deployment.id), }) } ) @@ -173,33 +161,24 @@ export const notifyDeploymentEvent = createAsyncThunk( +export const notifyReviewmentEvent = createAsyncThunk( "main/notifyReviewmentEvent", - async (event, { getState }) => { - const { user } = getState().main - if (event.kind !== EventKindEnum.Review) { - return - } - - if (event.type === EventTypeEnum.Created - && event.review?.user?.id === user?.id) { + async (review) => { + if (review.status === ReviewStatusEnum.Pending) { notify(`Review Requested`, { icon: "/logo192.png", - body: `${event.review?.deployment?.deployer?.login} requested the review for the deployment ${event.review?.deployment?.number} of ${event.review?.deployment?.repo?.namespace}/${event.review?.deployment?.repo?.name}`, - tag: String(event.id), + body: `${review.deployment?.deployer?.login} requested the review for the deployment ${review.deployment?.number} of ${review.deployment?.repo?.namespace}/${review.deployment?.repo?.name}`, + tag: String(review.id), }) return } - if (event.type === EventTypeEnum.Updated - && event.review?.deployment?.deployer?.id === user?.id) { - notify(`Review Responded`, { - icon: "/logo192.png", - body: `${event.review?.user?.login} ${event.review?.status} the deployment ${event.review?.deployment?.number} of ${event.review?.deployment?.repo?.namespace}/${event.review?.deployment?.repo?.name}`, - tag: String(event.id), - }) - return - } + notify(`Review Responded`, { + icon: "/logo192.png", + body: `${review.user?.login} ${review.status} the deployment ${review.deployment?.number} of ${review.deployment?.repo?.namespace}/${review.deployment?.repo?.name}`, + tag: String(review.id), + }) + return } ) @@ -218,60 +197,30 @@ export const mainSlice = createSlice({ state.expired = action.payload }, /** - * Handle all deployment events that the user can access. - * Note that some deployments are triggered by others. + * Update the status of the deployment with an event. */ - handleDeploymentEvent: (state, action: PayloadAction) => { - const event = action.payload - if (event.kind !== EventKindEnum.Deployment) { - return - } - - if (event.type === EventTypeEnum.Created - && event.deployment) { - state.deployments.unshift(event.deployment) + handleDeploymentEvent: (state, { payload: deployment }: PayloadAction) => { + if (deployment.status === DeploymentStatusEnum.Created) { + state.deployments.unshift(deployment) return } - // Update the deployment if it exist. - const idx = state.deployments.findIndex((deployment) => { - return event.deployment?.id === deployment.id + state.deployments = state.deployments.filter((item) => { + return !(item.status === DeploymentStatusEnum.Success + || item.status === DeploymentStatusEnum.Failure) }) - if (idx !== -1 ) { - if (!(event.deployment?.status === DeploymentStatusEnum.Waiting - || event.deployment?.status === DeploymentStatusEnum.Created - || event.deployment?.status === DeploymentStatusEnum.Queued - || event.deployment?.status === DeploymentStatusEnum.Running)) { - state.deployments.splice(idx, 1) - return - } - - state.deployments[idx] = event.deployment - return - } + state.deployments = state.deployments.map((item) => { + return (item.id === deployment.id)? deployment : item + }) }, - handleReviewEvent: (state, action: PayloadAction) => { - const event = action.payload - if (action.payload.kind !== EventKindEnum.Review) { - return - } - - if (event.type === EventTypeEnum.Created - && event.review - && event.review?.user?.id === state.user?.id) { - state.reviews.unshift(event.review) - return - } - - const idx = state.reviews.findIndex((review) => { - return event.review?.id === review.id + /** + * Reviews are removed from the state. + */ + handleReviewEvent: (state, { payload: review }: PayloadAction) => { + state.reviews = state.reviews.filter((item) => { + return item.id !== review.id }) - - if (idx !== -1) { - state.reviews.splice(idx, 1) - return - } }, }, extraReducers: builder => { diff --git a/ui/src/redux/repoHome.ts b/ui/src/redux/repoHome.ts index 565d4e9c..8ce4f832 100644 --- a/ui/src/redux/repoHome.ts +++ b/ui/src/redux/repoHome.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit' -import { Deployment, Event, EventTypeEnum } from '../models' +import { Deployment } from '../models' import { listDeployments, getConfig } from '../apis' export const perPage = 20; @@ -62,27 +62,6 @@ export const repoHomeSlice = createSlice({ decreasePage: (state) => { state.page = state.page - 1 }, - handleDeploymentEvent: (state, action: PayloadAction) => { - const event = action.payload - - if (!(event.deployment?.repo?.namespace === state.namespace - && event.deployment?.repo?.name === state.name)) { - return - } - - if (!(state.env === "" || event.deployment?.env === state.env)) { - return - } - - if (event.type === EventTypeEnum.Created && event.deployment) { - state.deployments.unshift(event.deployment) - return - } - - state.deployments = state.deployments.map((deployment) => { - return (event.deployment?.id === deployment.id)? event.deployment : deployment - }) - }, }, extraReducers: builder => { builder diff --git a/ui/src/views/deployment/index.tsx b/ui/src/views/deployment/index.tsx index 364cacfe..1ae3f935 100644 --- a/ui/src/views/deployment/index.tsx +++ b/ui/src/views/deployment/index.tsx @@ -22,7 +22,10 @@ import { ReviewStatusEnum, RequestStatus } from "../../models" -import { subscribeEvents } from "../../apis" +import { + subscribeDeploymentEvents, + subscribeReviewEvents +} from "../../apis" import Main from "../main" import HeaderBreadcrumb, { HeaderBreadcrumbProps } from "./HeaderBreadcrumb" @@ -61,13 +64,17 @@ export default (): JSX.Element => { } f() - const sub = subscribeEvents((event) => { - dispatch(slice.actions.handleDeploymentEvent(event)) - dispatch(slice.actions.handleReviewEvent(event)) + const deploymentEvent = subscribeDeploymentEvents((deployment) => { + dispatch(slice.actions.handleDeploymentEvent(deployment)) + }) + + const reviewEvent = subscribeReviewEvents((review) => { + dispatch(slice.actions.handleReviewEvent(review)) }) return () => { - sub.close() + deploymentEvent.close() + reviewEvent.close() } // eslint-disable-next-line }, [dispatch]) diff --git a/ui/src/views/home/index.tsx b/ui/src/views/home/index.tsx index 136e9500..0dc66d8f 100644 --- a/ui/src/views/home/index.tsx +++ b/ui/src/views/home/index.tsx @@ -5,9 +5,8 @@ import { Input, Breadcrumb, Button } from 'antd' import { RedoOutlined } from "@ant-design/icons" import { useAppSelector, useAppDispatch } from '../../redux/hooks' -import { homeSlice, listRepos, perPage, sync, homeSlice as slice } from '../../redux/home' +import { homeSlice, listRepos, perPage, sync } from '../../redux/home' import { RequestStatus } from '../../models' -import { subscribeEvents } from "../../apis" import Main from '../main' import RepoList, { RepoListProps } from './RepoList' @@ -24,14 +23,6 @@ export default ():JSX.Element => { useEffect(() => { dispatch(listRepos()) - - const sub = subscribeEvents((event) => { - dispatch(slice.actions.handleDeploymentEvent(event)) - }) - - return () => { - sub.close() - } }, [dispatch]) const search = (q: string) => { diff --git a/ui/src/views/main/index.tsx b/ui/src/views/main/index.tsx index d00599a1..b1716eba 100644 --- a/ui/src/views/main/index.tsx +++ b/ui/src/views/main/index.tsx @@ -5,7 +5,7 @@ import { Helmet } from "react-helmet" import moment from "moment" import { useAppSelector, useAppDispatch } from "../../redux/hooks" -import { subscribeEvents } from "../../apis" +import { subscribeDeploymentEvents, subscribeReviewEvents } from "../../apis" import { init, searchDeployments, @@ -40,15 +40,19 @@ export default (props: React.PropsWithChildren): JSX.Element => { dispatch(searchReviews()) dispatch(fetchLicense()) - const sub = subscribeEvents((event) => { - dispatch(slice.actions.handleDeploymentEvent(event)) - dispatch(slice.actions.handleReviewEvent(event)) - dispatch(notifyDeploymentEvent(event)) - dispatch(notifyReviewmentEvent(event)) + const deploymentEvents = subscribeDeploymentEvents((deployment) => { + dispatch(slice.actions.handleDeploymentEvent(deployment)) + dispatch(notifyDeploymentEvent(deployment)) + }) + + const reviewEvents = subscribeReviewEvents((review) => { + dispatch(slice.actions.handleReviewEvent(review)) + dispatch(notifyReviewmentEvent(review)) }) return () => { - sub.close() + deploymentEvents.close() + reviewEvents.close() } }, [dispatch]) diff --git a/ui/src/views/repoHome/index.tsx b/ui/src/views/repoHome/index.tsx index 355b72b3..cec47a12 100644 --- a/ui/src/views/repoHome/index.tsx +++ b/ui/src/views/repoHome/index.tsx @@ -5,7 +5,6 @@ import { PageHeader, Select } from 'antd' import { useAppSelector, useAppDispatch } from '../../redux/hooks' import { repoHomeSlice as slice, fetchEnvs, fetchDeployments, perPage } from '../../redux/repoHome' -import { subscribeEvents } from "../../apis" import ActivityLogs, { ActivityLogsProps } from './ActivityLogs' import Spin from '../../components/Spin' @@ -36,14 +35,6 @@ export default (): JSX.Element => { } f() - const sub = subscribeEvents((event) => { - dispatch(slice.actions.handleDeploymentEvent(event)) - }) - - return () => { - sub.close() - } - // eslint-disable-next-line }, [dispatch]) const onChangeEnv = (env: string) => {