From 772aa2a5c11c38a4e2235ea901a8cd65bb46edd1 Mon Sep 17 00:00:00 2001 From: Guna K Kambalimath Date: Tue, 30 Jun 2026 10:44:12 +0530 Subject: [PATCH 1/2] Chat support feature Signed-off-by: Guna K Kambalimath --- api/Makefile | 2 +- .../app/v1alpha1/zz_generated.deepcopy.go | 32 +- api/config/crd/bases/app.pac.io_catalogs.yaml | 28 +- api/config/crd/bases/app.pac.io_configs.yaml | 38 ++- api/config/crd/bases/app.pac.io_services.yaml | 32 +- .../crd/bases/manageiq.pac.io_services.yaml | 58 ++-- api/config/rbac/role.yaml | 61 +--- api/go.mod | 1 + api/go.sum | 2 + .../pkg/pac-go-server/db/interface.go | 5 + .../pkg/pac-go-server/db/mock_db_client.go | 218 ++++++++----- .../pkg/pac-go-server/db/mongodb/chat.go | 72 +++++ api/internal/pkg/pac-go-server/models/chat.go | 23 ++ .../pkg/pac-go-server/router/router.go | 3 + .../pkg/pac-go-server/services/chat.go | 131 ++++++++ web/src/components/App.jsx | 4 + web/src/components/ChatSupport.jsx | 265 +++++++++++++++ web/src/components/Header.jsx | 3 + web/src/styles/chat-support.scss | 303 ++++++++++++++++++ 19 files changed, 1067 insertions(+), 214 deletions(-) create mode 100644 api/internal/pkg/pac-go-server/db/mongodb/chat.go create mode 100644 api/internal/pkg/pac-go-server/models/chat.go create mode 100644 api/internal/pkg/pac-go-server/services/chat.go create mode 100644 web/src/components/ChatSupport.jsx create mode 100644 web/src/styles/chat-support.scss diff --git a/api/Makefile b/api/Makefile index 26a7517d..fd3fdc01 100644 --- a/api/Makefile +++ b/api/Makefile @@ -143,7 +143,7 @@ SWAGGER ?= $(LOCALBIN)/swag ## Tool Versions KUSTOMIZE_VERSION ?= v3.8.7 -CONTROLLER_TOOLS_VERSION ?= v0.9.2 +CONTROLLER_TOOLS_VERSION ?= v0.16.5 KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" .PHONY: kustomize diff --git a/api/apis/app/v1alpha1/zz_generated.deepcopy.go b/api/apis/app/v1alpha1/zz_generated.deepcopy.go index 53f5c6a1..cd6ba4ec 100644 --- a/api/apis/app/v1alpha1/zz_generated.deepcopy.go +++ b/api/apis/app/v1alpha1/zz_generated.deepcopy.go @@ -44,7 +44,7 @@ func (in *Catalog) DeepCopyInto(out *Catalog) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -102,7 +102,7 @@ func (in *CatalogList) DeepCopyObject() runtime.Object { func (in *CatalogSpec) DeepCopyInto(out *CatalogSpec) { *out = *in out.Capacity = in.Capacity - out.VM = in.VM + in.VM.DeepCopyInto(&out.VM) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CatalogSpec. @@ -243,7 +243,7 @@ func (in *Service) DeepCopyInto(out *Service) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Service. @@ -321,7 +321,7 @@ func (in *ServiceSpec) DeepCopy() *ServiceSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceStatus) DeepCopyInto(out *ServiceStatus) { *out = *in - out.VM = in.VM + in.VM.DeepCopyInto(&out.VM) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceStatus. @@ -337,6 +337,10 @@ func (in *ServiceStatus) DeepCopy() *ServiceStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VM) DeepCopyInto(out *VM) { *out = *in + if in.CreatedAt != nil { + in, out := &in.CreatedAt, &out.CreatedAt + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VM. @@ -353,6 +357,11 @@ func (in *VM) DeepCopy() *VM { func (in *VMCatalog) DeepCopyInto(out *VMCatalog) { *out = *in out.Capacity = in.Capacity + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]VolumeSpec, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMCatalog. @@ -379,3 +388,18 @@ func (in *VPCConfig) DeepCopy() *VPCConfig { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VolumeSpec) DeepCopyInto(out *VolumeSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeSpec. +func (in *VolumeSpec) DeepCopy() *VolumeSpec { + if in == nil { + return nil + } + out := new(VolumeSpec) + in.DeepCopyInto(out) + return out +} diff --git a/api/config/crd/bases/app.pac.io_catalogs.yaml b/api/config/crd/bases/app.pac.io_catalogs.yaml index 6139e499..0375345b 100644 --- a/api/config/crd/bases/app.pac.io_catalogs.yaml +++ b/api/config/crd/bases/app.pac.io_catalogs.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.9.2 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.16.5 name: catalogs.app.pac.io spec: group: app.pac.io @@ -21,14 +20,19 @@ spec: description: Catalog is the Schema for the catalogs API properties: apiVersion: - description: 'APIVersion defines the versioned schema of this representation - of an object. Servers should convert recognized schemas to the latest - internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: - description: 'Kind is a string value representing the REST resource this - object represents. Servers may infer this from the endpoint the client - submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object @@ -104,16 +108,16 @@ spec: default: tier3 description: Type of disk (tier1, tier3, fixed IOPS, etc.) type: string - volumeNameSuffix: - description: Name of the volume to create - type: string size: description: Size of the volume in GB minimum: 1 type: integer + volumeNameSuffix: + description: Name of the volume to create + type: string required: - - name - size + - volumeNameSuffix type: object type: array required: diff --git a/api/config/crd/bases/app.pac.io_configs.yaml b/api/config/crd/bases/app.pac.io_configs.yaml index d62eb96e..42b96261 100644 --- a/api/config/crd/bases/app.pac.io_configs.yaml +++ b/api/config/crd/bases/app.pac.io_configs.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.9.2 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.16.5 name: configs.app.pac.io spec: group: app.pac.io @@ -21,20 +20,24 @@ spec: description: Config is the Schema for the configs API properties: apiVersion: - description: 'APIVersion defines the versioned schema of this representation - of an object. Servers should convert recognized schemas to the latest - internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: - description: 'Kind is a string value representing the REST resource this - object represents. Servers may infer this from the endpoint the client - submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: - description: 'ConfigSpec defines the desired state of Config TODO: Add - appropriate kubebuilder markers for the field' + description: ConfigSpec defines the desired state of Config properties: MIQClientID: description: MIQClientID is the client ID created in the keycloak @@ -47,14 +50,16 @@ spec: description: MIQUserName is the user name used for talking ManageIQ type: string credentialSecret: - description: 'CredentialSecret is the secret contains the credential - like MIQ password, ClientSecret Secret contains the following data: - miq-password: miq-client-password: ' + description: |- + CredentialSecret is the secret contains the credential like MIQ password, ClientSecret + Secret contains the following data: + miq-password: + miq-client-password: properties: name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid?' + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string type: object x-kubernetes-map-type: atomic @@ -85,7 +90,6 @@ spec: region: type: string zone: - description: 'TODO: remove if not required' type: string required: - ID diff --git a/api/config/crd/bases/app.pac.io_services.yaml b/api/config/crd/bases/app.pac.io_services.yaml index 8eac86bd..03b3b78d 100644 --- a/api/config/crd/bases/app.pac.io_services.yaml +++ b/api/config/crd/bases/app.pac.io_services.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.9.2 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.16.5 name: services.app.pac.io spec: group: app.pac.io @@ -21,14 +20,19 @@ spec: description: Service is the Schema for the services API properties: apiVersion: - description: 'APIVersion defines the versioned schema of this representation - of an object. Servers should convert recognized schemas to the latest - internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: - description: 'Kind is a string value representing the REST resource this - object represents. Servers may infer this from the endpoint the client - submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object @@ -36,12 +40,14 @@ spec: description: ServiceSpec defines the desired state of Service properties: catalog: - description: LocalObjectReference contains enough information to let - you locate the referenced object inside the same namespace. + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. properties: name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid?' + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string type: object x-kubernetes-map-type: atomic @@ -107,6 +113,8 @@ spec: type: string type: object type: object + required: + - spec type: object served: true storage: true diff --git a/api/config/crd/bases/manageiq.pac.io_services.yaml b/api/config/crd/bases/manageiq.pac.io_services.yaml index 58483379..25423237 100644 --- a/api/config/crd/bases/manageiq.pac.io_services.yaml +++ b/api/config/crd/bases/manageiq.pac.io_services.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.9.2 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.16.5 name: services.manageiq.pac.io spec: group: manageiq.pac.io @@ -42,14 +41,19 @@ spec: description: Service is the Schema for the services API properties: apiVersion: - description: 'APIVersion defines the versioned schema of this representation - of an object. Servers should convert recognized schemas to the latest - internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: - description: 'Kind is a string value representing the REST resource this - object represents. Servers may infer this from the endpoint the client - submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object @@ -129,37 +133,37 @@ spec: operational state. properties: lastTransitionTime: - description: Last time the condition transitioned from one status - to another. This should be when the underlying condition changed. - If that is not known, then using the time when the API field - changed is acceptable. + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. format: date-time type: string message: - description: A human readable message indicating details about - the transition. This field may be empty. + description: |- + A human readable message indicating details about the transition. + This field may be empty. type: string reason: - description: The reason for the condition's last transition - in CamelCase. The specific API may choose whether or not this - field is considered a guaranteed API. This field may not be - empty. + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may not be empty. type: string severity: - description: Severity provides an explicit classification of - Reason code, so the users or machines can immediately understand - the current situation and act accordingly. The Severity field - MUST be set only when Status=False. + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. type: string status: description: Status of the condition, one of True, False, Unknown. type: string type: - description: Type of condition in CamelCase or in foo.example.com/CamelCase. - Many .condition.type values are consistent across resources - like Available, but because arbitrary conditions can be useful - (see .node.status.conditions), the ability to deconflict is - important. + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. type: string required: - lastTransitionTime diff --git a/api/config/rbac/role.yaml b/api/config/rbac/role.yaml index 96ecabba..f37ebf9f 100644 --- a/api/config/rbac/role.yaml +++ b/api/config/rbac/role.yaml @@ -2,64 +2,21 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - creationTimestamp: null name: manager-role rules: - apiGroups: - - app.pac.io + - "" resources: - - catalogs + - secrets verbs: - - create - - delete - get - list - - patch - - update - watch - apiGroups: - app.pac.io resources: - - catalogs/finalizers - verbs: - - update -- apiGroups: - - app.pac.io - resources: - - catalogs/status - verbs: - - get - - patch - - update -- apiGroups: - - app.pac.io - resources: + - catalogs - configs - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - app.pac.io - resources: - - configs/finalizers - verbs: - - update -- apiGroups: - - app.pac.io - resources: - - configs/status - verbs: - - get - - patch - - update -- apiGroups: - - app.pac.io - resources: - services verbs: - create @@ -72,25 +29,21 @@ rules: - apiGroups: - app.pac.io resources: + - catalogs/finalizers + - configs/finalizers - services/finalizers verbs: - update - apiGroups: - app.pac.io resources: + - catalogs/status + - configs/status - services/status verbs: - get - patch - update -- apiGroups: - - "" - resources: - - secrets - verbs: - - get - - list - - watch - apiGroups: - manageiq.pac.io resources: diff --git a/api/go.mod b/api/go.mod index 0fe8161e..a108366e 100644 --- a/api/go.mod +++ b/api/go.mod @@ -87,6 +87,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/imdario/mergo v0.3.13 // indirect diff --git a/api/go.sum b/api/go.sum index 95fd4351..b79332af 100644 --- a/api/go.sum +++ b/api/go.sum @@ -197,6 +197,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= diff --git a/api/internal/pkg/pac-go-server/db/interface.go b/api/internal/pkg/pac-go-server/db/interface.go index 534f2342..e4e0b942 100644 --- a/api/internal/pkg/pac-go-server/db/interface.go +++ b/api/internal/pkg/pac-go-server/db/interface.go @@ -56,4 +56,9 @@ type DB interface { CreateMaintenanceWindow(window *models.MaintenanceWindow) error UpdateMaintenanceWindow(window *models.MaintenanceWindow) error DeleteMaintenanceWindow(id string, deletedBy string, deletedAt *time.Time) error + + // Chat operations + InsertChatMessage(*models.ChatMessage) error + GetNextConversationID(context.Context, string) (int64, error) + GetChatMessages(context.Context, string, int64) ([]models.ChatMessage, error) } diff --git a/api/internal/pkg/pac-go-server/db/mock_db_client.go b/api/internal/pkg/pac-go-server/db/mock_db_client.go index c06638ed..3ab818ab 100644 --- a/api/internal/pkg/pac-go-server/db/mock_db_client.go +++ b/api/internal/pkg/pac-go-server/db/mock_db_client.go @@ -5,9 +5,9 @@ package db import ( - "context" + context "context" reflect "reflect" - "time" + time "time" models "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" gomock "github.com/golang/mock/gomock" @@ -107,6 +107,20 @@ func (mr *MockDBMockRecorder) CreateKey(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateKey", reflect.TypeOf((*MockDB)(nil).CreateKey), arg0) } +// CreateMaintenanceWindow mocks base method. +func (m *MockDB) CreateMaintenanceWindow(arg0 *models.MaintenanceWindow) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateMaintenanceWindow", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateMaintenanceWindow indicates an expected call of CreateMaintenanceWindow. +func (mr *MockDBMockRecorder) CreateMaintenanceWindow(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).CreateMaintenanceWindow), arg0) +} + // DeleteKey mocks base method. func (m *MockDB) DeleteKey(arg0 string) error { m.ctrl.T.Helper() @@ -121,6 +135,20 @@ func (mr *MockDBMockRecorder) DeleteKey(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteKey", reflect.TypeOf((*MockDB)(nil).DeleteKey), arg0) } +// DeleteMaintenanceWindow mocks base method. +func (m *MockDB) DeleteMaintenanceWindow(arg0, arg1 string, arg2 *time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteMaintenanceWindow", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteMaintenanceWindow indicates an expected call of DeleteMaintenanceWindow. +func (mr *MockDBMockRecorder) DeleteMaintenanceWindow(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).DeleteMaintenanceWindow), arg0, arg1, arg2) +} + // DeleteQuota mocks base method. func (m *MockDB) DeleteQuota(arg0 string) error { m.ctrl.T.Helper() @@ -177,6 +205,51 @@ func (mr *MockDBMockRecorder) Disconnect() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockDB)(nil).Disconnect)) } +// FeedbackAllowed mocks base method. +func (m *MockDB) FeedbackAllowed(arg0 context.Context, arg1 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FeedbackAllowed", arg0, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FeedbackAllowed indicates an expected call of FeedbackAllowed. +func (mr *MockDBMockRecorder) FeedbackAllowed(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FeedbackAllowed", reflect.TypeOf((*MockDB)(nil).FeedbackAllowed), arg0, arg1) +} + +// GetAllMaintenanceWindows mocks base method. +func (m *MockDB) GetAllMaintenanceWindows() ([]models.MaintenanceWindow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllMaintenanceWindows") + ret0, _ := ret[0].([]models.MaintenanceWindow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllMaintenanceWindows indicates an expected call of GetAllMaintenanceWindows. +func (mr *MockDBMockRecorder) GetAllMaintenanceWindows() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllMaintenanceWindows", reflect.TypeOf((*MockDB)(nil).GetAllMaintenanceWindows)) +} + +// GetChatMessages mocks base method. +func (m *MockDB) GetChatMessages(arg0 context.Context, arg1 string, arg2 int64) ([]models.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessages", arg0, arg1, arg2) + ret0, _ := ret[0].([]models.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessages indicates an expected call of GetChatMessages. +func (mr *MockDBMockRecorder) GetChatMessages(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessages", reflect.TypeOf((*MockDB)(nil).GetChatMessages), arg0, arg1, arg2) +} + // GetEventsByType mocks base method. func (m *MockDB) GetEventsByType(arg0 models.EventType, arg1 uint) ([]models.Event, int64, error) { m.ctrl.T.Helper() @@ -270,6 +343,36 @@ func (mr *MockDBMockRecorder) GetKeyByUserID(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeyByUserID", reflect.TypeOf((*MockDB)(nil).GetKeyByUserID), arg0) } +// GetMaintenanceWindowByID mocks base method. +func (m *MockDB) GetMaintenanceWindowByID(arg0 string) (*models.MaintenanceWindow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMaintenanceWindowByID", arg0) + ret0, _ := ret[0].(*models.MaintenanceWindow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMaintenanceWindowByID indicates an expected call of GetMaintenanceWindowByID. +func (mr *MockDBMockRecorder) GetMaintenanceWindowByID(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaintenanceWindowByID", reflect.TypeOf((*MockDB)(nil).GetMaintenanceWindowByID), arg0) +} + +// GetNextConversationID mocks base method. +func (m *MockDB) GetNextConversationID(arg0 context.Context, arg1 string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNextConversationID", arg0, arg1) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNextConversationID indicates an expected call of GetNextConversationID. +func (mr *MockDBMockRecorder) GetNextConversationID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNextConversationID", reflect.TypeOf((*MockDB)(nil).GetNextConversationID), arg0, arg1) +} + // GetQuotaForGroupID mocks base method. func (m *MockDB) GetQuotaForGroupID(arg0 string) (*models.Quota, error) { m.ctrl.T.Helper() @@ -360,105 +463,32 @@ func (mr *MockDBMockRecorder) GetTermsAndConditionsByUserID(arg0 interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTermsAndConditionsByUserID", reflect.TypeOf((*MockDB)(nil).GetTermsAndConditionsByUserID), arg0) } -// InsertFeedback mocks base method. -func (m *MockDB) InsertFeedback(arg0 *models.Feedback) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertFeedback", arg0) - ret0, _ := ret[0].(error) - return ret0 -} - -// InsertFeedback indicates an expected call of InsertFeedback. -func (mr *MockDBMockRecorder) InsertFeedback(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertFeedback", reflect.TypeOf((*MockDB)(nil).InsertFeedback), arg0) -} - -// FeedbackAllowed mocks base method. -func (m *MockDB) FeedbackAllowed(arg0 context.Context, arg1 string) (bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FeedbackAllowed", arg0, arg1) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// FeedbackAllowed indicates an expected call of FeedbackAllowed. -func (mr *MockDBMockRecorder) FeedbackAllowed(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FeedbackAllowed", reflect.TypeOf((*MockDB)(nil).FeedbackAllowed), arg0, arg1) -} - -// GetAllMaintenanceWindows mocks base method. -func (m *MockDB) GetAllMaintenanceWindows() ([]models.MaintenanceWindow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllMaintenanceWindows") - ret0, _ := ret[0].([]models.MaintenanceWindow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllMaintenanceWindows indicates an expected call of GetAllMaintenanceWindows. -func (mr *MockDBMockRecorder) GetAllMaintenanceWindows() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllMaintenanceWindows", reflect.TypeOf((*MockDB)(nil).GetAllMaintenanceWindows)) -} - -// GetMaintenanceWindowByID mocks base method. -func (m *MockDB) GetMaintenanceWindowByID(arg0 string) (*models.MaintenanceWindow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMaintenanceWindowByID", arg0) - ret0, _ := ret[0].(*models.MaintenanceWindow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMaintenanceWindowByID indicates an expected call of GetMaintenanceWindowByID. -func (mr *MockDBMockRecorder) GetMaintenanceWindowByID(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaintenanceWindowByID", reflect.TypeOf((*MockDB)(nil).GetMaintenanceWindowByID), arg0) -} - -// CreateMaintenanceWindow mocks base method. -func (m *MockDB) CreateMaintenanceWindow(arg0 *models.MaintenanceWindow) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateMaintenanceWindow", arg0) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateMaintenanceWindow indicates an expected call of CreateMaintenanceWindow. -func (mr *MockDBMockRecorder) CreateMaintenanceWindow(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).CreateMaintenanceWindow), arg0) -} - -// UpdateMaintenanceWindow mocks base method. -func (m *MockDB) UpdateMaintenanceWindow(arg0 *models.MaintenanceWindow) error { +// InsertChatMessage mocks base method. +func (m *MockDB) InsertChatMessage(arg0 *models.ChatMessage) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateMaintenanceWindow", arg0) + ret := m.ctrl.Call(m, "InsertChatMessage", arg0) ret0, _ := ret[0].(error) return ret0 } -// UpdateMaintenanceWindow indicates an expected call of UpdateMaintenanceWindow. -func (mr *MockDBMockRecorder) UpdateMaintenanceWindow(arg0 interface{}) *gomock.Call { +// InsertChatMessage indicates an expected call of InsertChatMessage. +func (mr *MockDBMockRecorder) InsertChatMessage(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).UpdateMaintenanceWindow), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessage", reflect.TypeOf((*MockDB)(nil).InsertChatMessage), arg0) } -// DeleteMaintenanceWindow mocks base method. -func (m *MockDB) DeleteMaintenanceWindow(arg0 string, arg1 string, arg2 *time.Time) error { +// InsertFeedback mocks base method. +func (m *MockDB) InsertFeedback(arg0 *models.Feedback) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteMaintenanceWindow", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "InsertFeedback", arg0) ret0, _ := ret[0].(error) return ret0 } -// DeleteMaintenanceWindow indicates an expected call of DeleteMaintenanceWindow. -func (mr *MockDBMockRecorder) DeleteMaintenanceWindow(arg0, arg1, arg2 interface{}) *gomock.Call { +// InsertFeedback indicates an expected call of InsertFeedback. +func (mr *MockDBMockRecorder) InsertFeedback(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).DeleteMaintenanceWindow), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertFeedback", reflect.TypeOf((*MockDB)(nil).InsertFeedback), arg0) } // MarkEventAsNotified mocks base method. @@ -532,6 +562,20 @@ func (mr *MockDBMockRecorder) SetEventCapping(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEventCapping", reflect.TypeOf((*MockDB)(nil).SetEventCapping), arg0) } +// UpdateMaintenanceWindow mocks base method. +func (m *MockDB) UpdateMaintenanceWindow(arg0 *models.MaintenanceWindow) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateMaintenanceWindow", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateMaintenanceWindow indicates an expected call of UpdateMaintenanceWindow. +func (mr *MockDBMockRecorder) UpdateMaintenanceWindow(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).UpdateMaintenanceWindow), arg0) +} + // UpdateQuota mocks base method. func (m *MockDB) UpdateQuota(arg0 *models.Quota) error { m.ctrl.T.Helper() diff --git a/api/internal/pkg/pac-go-server/db/mongodb/chat.go b/api/internal/pkg/pac-go-server/db/mongodb/chat.go new file mode 100644 index 00000000..17dfadb2 --- /dev/null +++ b/api/internal/pkg/pac-go-server/db/mongodb/chat.go @@ -0,0 +1,72 @@ +package mongodb + +import ( + "context" + "fmt" + + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// InsertChatMessage - insert chat message into the DB +func (db *MongoDB) InsertChatMessage(message *models.ChatMessage) error { + collection := db.Database.Collection("chat_messages") + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + if _, err := collection.InsertOne(ctx, message); err != nil { + return fmt.Errorf("error inserting chat message: %w", err) + } + + return nil +} + +// GetNextConversationID - get the next available conversation ID for a user +func (db *MongoDB) GetNextConversationID(ctx context.Context, userID string) (int64, error) { + collection := db.Database.Collection("chat_messages") + + // Find the highest conversation_id for this user + opts := options.FindOne().SetSort(bson.D{{Key: "conversation_id", Value: -1}}) + filter := bson.M{"user_id": userID} + + var result models.ChatMessage + err := collection.FindOne(ctx, filter, opts).Decode(&result) + + if err != nil { + // If no documents found, this is the first conversation + if err.Error() == "mongo: no documents in result" { + return 1, nil + } + return 0, fmt.Errorf("error finding max conversation_id: %w", err) + } + + // Return next conversation ID + return result.ConversationID + 1, nil +} + +// GetChatMessages - get all messages for a specific conversation +func (db *MongoDB) GetChatMessages(ctx context.Context, userID string, conversationID int64) ([]models.ChatMessage, error) { + collection := db.Database.Collection("chat_messages") + + filter := bson.M{ + "user_id": userID, + "conversation_id": conversationID, + } + + // Sort by timestamp ascending (oldest first) + opts := options.Find().SetSort(bson.D{{Key: "timestamp", Value: 1}}) + + cursor, err := collection.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("error fetching chat messages: %w", err) + } + defer cursor.Close(ctx) + + var messages []models.ChatMessage + if err := cursor.All(ctx, &messages); err != nil { + return nil, fmt.Errorf("error decoding chat messages: %w", err) + } + + return messages, nil +} diff --git a/api/internal/pkg/pac-go-server/models/chat.go b/api/internal/pkg/pac-go-server/models/chat.go new file mode 100644 index 00000000..3b05385a --- /dev/null +++ b/api/internal/pkg/pac-go-server/models/chat.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// ChatMessage represents a single chat message in a conversation +type ChatMessage struct { + // ID is the unique identifier for the message + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + // ConversationID is the sequential conversation identifier (1, 2, 3...) + ConversationID int64 `json:"conversation_id" bson:"conversation_id"` + // UserID is the Keycloak user identifier + UserID string `json:"user_id" bson:"user_id"` + // Message is the text content of the message + Message string `json:"message" bson:"message"` + // Sender indicates who sent the message ("user" or "admin") + Sender string `json:"sender" bson:"sender"` + // Timestamp is when the message was created + Timestamp time.Time `json:"timestamp" bson:"timestamp"` +} diff --git a/api/internal/pkg/pac-go-server/router/router.go b/api/internal/pkg/pac-go-server/router/router.go index 00f73942..0e6b8704 100644 --- a/api/internal/pkg/pac-go-server/router/router.go +++ b/api/internal/pkg/pac-go-server/router/router.go @@ -128,6 +128,9 @@ func CreateRouter() *gin.Engine { // feedback related endpoints authorized.POST("/feedbacks", services.CreateFeedback) + // chat websocket endpoint + authorized.GET("/chat", services.HandleChatWebSocket) + // maintenance notification related endpoints authorized.GET("/maintenance", services.GetMaintenanceWindows) diff --git a/api/internal/pkg/pac-go-server/services/chat.go b/api/internal/pkg/pac-go-server/services/chat.go new file mode 100644 index 00000000..1c3c18cf --- /dev/null +++ b/api/internal/pkg/pac-go-server/services/chat.go @@ -0,0 +1,131 @@ +package services + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "go.uber.org/zap" + + log "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/logger" + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // Allow all origins for now - should be restricted in production + return true + }, +} + +// HandleChatWebSocket handles WebSocket connections for chat +func HandleChatWebSocket(c *gin.Context) { + logger := log.GetLogger() + + // Get user ID from context (set by Keycloak middleware) + userID, exists := c.Get("userid") + if !exists { + logger.Error("user ID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userIDStr := userID.(string) + logger.Info("WebSocket connection request", zap.String("userID", userIDStr)) + + // Upgrade HTTP connection to WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Error("failed to upgrade to websocket", zap.Error(err)) + return + } + defer conn.Close() + + logger.Info("WebSocket connection established", zap.String("userID", userIDStr)) + + // Get or create conversation ID for this user + ctx := context.Background() + conversationID, err := dbCon.GetNextConversationID(ctx, userIDStr) + if err != nil { + logger.Error("failed to get conversation ID", zap.Error(err)) + conn.WriteJSON(gin.H{"error": "Failed to initialize conversation"}) + return + } + + logger.Info("Conversation initialized", + zap.String("userID", userIDStr), + zap.Int64("conversationID", conversationID)) + + // Handle incoming messages + for { + var incomingMsg models.ChatMessage + err := conn.ReadJSON(&incomingMsg) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + logger.Error("websocket error", zap.Error(err)) + } else { + logger.Info("websocket connection closed", zap.String("userID", userIDStr)) + } + break + } + + logger.Info("Received message", + zap.String("userID", userIDStr), + zap.String("message", incomingMsg.Message)) + + // Create user message + userMessage := &models.ChatMessage{ + ConversationID: conversationID, + UserID: userIDStr, + Message: incomingMsg.Message, + Sender: "user", + Timestamp: time.Now(), + } + + // Save user message to MongoDB + if err := dbCon.InsertChatMessage(userMessage); err != nil { + logger.Error("failed to save user message", zap.Error(err)) + conn.WriteJSON(gin.H{"error": "Failed to save message"}) + continue + } + + logger.Info("User message saved to database") + + // Send automated "test" response + adminMessage := &models.ChatMessage{ + ConversationID: conversationID, + UserID: userIDStr, + Message: "test", + Sender: "admin", + Timestamp: time.Now(), + } + + // Save admin response to MongoDB + if err := dbCon.InsertChatMessage(adminMessage); err != nil { + logger.Error("failed to save admin message", zap.Error(err)) + conn.WriteJSON(gin.H{"error": "Failed to save response"}) + continue + } + + logger.Info("Admin response saved to database") + + // Send response back to client + response := map[string]interface{}{ + "conversation_id": conversationID, + "message": "test", + "sender": "admin", + "timestamp": adminMessage.Timestamp.Format(time.RFC3339), + } + + if err := conn.WriteJSON(response); err != nil { + logger.Error("failed to send response", zap.Error(err)) + break + } + + logger.Info("Response sent to client", zap.String("userID", userIDStr)) + } +} diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 0fc61a71..56968cec 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -22,6 +22,7 @@ import { Theme } from "@carbon/react"; import Feedbacks from "./Feedbacks"; import MaintenanceNotification from "./MaintenanceNotification"; import MaintenanceManager from "./MaintenanceManager"; +import ChatSupport from "./ChatSupport"; const RouterClass = React.memo(({ isAdmin }) => { return ( @@ -46,6 +47,9 @@ const RouterClass = React.memo(({ isAdmin }) => { {!isAdmin && ( } /> )} + {!isAdmin && ( + } /> + )} {isAdmin && ( { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(""); + const [conversationId, setConversationId] = useState(null); + const [ws, setWs] = useState(null); + const [connectionStatus, setConnectionStatus] = useState("connecting"); + const messagesEndRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const getWebSocketUrl = () => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host; + const token = UserService.getToken(); + return `${protocol}//${host}/pac-go-server/chat?token=${encodeURIComponent(token)}`; + }; + + const connectWebSocket = () => { + try { + const wsUrl = getWebSocketUrl(); + console.log("Connecting to WebSocket:", wsUrl.replace(/token=[^&]+/, "token=***")); + + const websocket = new WebSocket(wsUrl); + + websocket.onopen = () => { + console.log("WebSocket connected"); + setConnectionStatus("connected"); + }; + + websocket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log("Received message:", data); + + // Set conversation ID from first response + if (data.conversation_id && !conversationId) { + setConversationId(`conv-${data.conversation_id}`); + } + + // Add admin message to chat + if (data.message && data.sender === "admin") { + const adminMessage = { + id: Date.now(), + sender: "Admin", + content: data.message, + timestamp: data.timestamp ? new Date(data.timestamp) : new Date(), + }; + setMessages((prev) => [...prev, adminMessage]); + } + } catch (error) { + console.error("Error parsing message:", error); + } + }; + + websocket.onerror = (error) => { + console.error("WebSocket error:", error); + setConnectionStatus("error"); + }; + + websocket.onclose = () => { + console.log("WebSocket disconnected"); + setConnectionStatus("disconnected"); + + // Auto-reconnect after 3 seconds + reconnectTimeoutRef.current = setTimeout(() => { + console.log("Attempting to reconnect..."); + connectWebSocket(); + }, 3000); + }; + + setWs(websocket); + } catch (error) { + console.error("Error creating WebSocket:", error); + setConnectionStatus("error"); + } + }; + + useEffect(() => { + connectWebSocket(); + + // Cleanup on unmount + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (ws) { + ws.close(); + } + }; + }, []); + + const formatTime = (date) => { + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + }; + + const handleSendMessage = () => { + if (inputMessage.trim() === "" || !ws || ws.readyState !== WebSocket.OPEN) { + console.warn("Cannot send message: WebSocket not ready"); + return; + } + + const message = { + message: inputMessage, + }; + + try { + ws.send(JSON.stringify(message)); + console.log("Message sent:", message); + + // Add user message to UI immediately + const userMessage = { + id: Date.now(), + sender: "You", + content: inputMessage, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, userMessage]); + setInputMessage(""); + } catch (error) { + console.error("Error sending message:", error); + } + }; + + const handleKeyPress = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const getConnectionStatusText = () => { + switch (connectionStatus) { + case "connected": + return "Connected"; + case "connecting": + return "Connecting..."; + case "disconnected": + return "Disconnected (reconnecting...)"; + case "error": + return "Connection Error"; + default: + return "Unknown"; + } + }; + + return ( +
+ {/* Left Panel - Conversations */} +
+
+

Conversations

+
+ + +
+
+
+
+
+
U
+
+
+
+ {conversationId || "Your first conversation"} +
+
+ {getConnectionStatusText()} +
+
+ {messages.length > 0 ? `${messages.length} messages` : "Start chatting"} +
+
+
+
+
+ + {/* Right Panel - Chat Area */} +
+
+
+

Conversation ID: {conversationId || "Initializing..."}

+
+ + Conversation ID: {conversationId || "Initializing..."} + + | + + Status: {getConnectionStatusText()} + +
+
+
+ +
+ {messages.length === 0 ? ( +
+

Start your conversation by typing a message below

+
+ ) : ( + messages.map((message) => ( +
+
+
+ + [{message.sender}, {formatTime(message.timestamp)}]: + +
+
{message.content}
+
+
+ )) + )} +
+
+ +
+
+ setInputMessage(e.target.value)} + onKeyPress={handleKeyPress} + disabled={connectionStatus !== "connected"} + /> + +
+
+
+
+ ); +}; + +export default ChatSupport; diff --git a/web/src/components/Header.jsx b/web/src/components/Header.jsx index 06401854..5886bc0f 100644 --- a/web/src/components/Header.jsx +++ b/web/src/components/Header.jsx @@ -130,6 +130,9 @@ const HeaderNav = ({ onSideNavToggle }) => { setActionProps(action)}> Feedback + + Chat Support + Date: Tue, 7 Jul 2026 09:00:12 +0530 Subject: [PATCH 2/2] Chat support feature Signed-off-by: Guna K Kambalimath --- api/go.mod | 4 +- api/go.sum | 4 +- .../pkg/pac-go-server/services/chat.go | 47 ++++++++++--------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/api/go.mod b/api/go.mod index a108366e..cae04d2e 100644 --- a/api/go.mod +++ b/api/go.mod @@ -10,6 +10,7 @@ require ( github.com/IBM/platform-services-go-sdk v0.89.0 github.com/IBM/vpc-go-sdk v0.74.1 github.com/Nerzal/gocloak/v13 v13.9.0 + github.com/coder/websocket v1.8.15 github.com/gin-gonic/gin v1.11.0 github.com/go-logr/logr v1.4.3 github.com/go-playground/validator/v10 v10.28.0 @@ -31,6 +32,7 @@ require ( go.mongodb.org/mongo-driver v1.17.4 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.45.0 + golang.org/x/text v0.31.0 k8s.io/api v0.27.2 k8s.io/apimachinery v0.27.2 k8s.io/client-go v0.27.2 @@ -87,7 +89,6 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/imdario/mergo v0.3.13 // indirect @@ -136,7 +137,6 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.38.0 // indirect gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect diff --git a/api/go.sum b/api/go.sum index b79332af..d9d52bdd 100644 --- a/api/go.sum +++ b/api/go.sum @@ -53,6 +53,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coredns/caddy v1.1.0 h1:ezvsPrT/tA/7pYDBZxu0cT0VmWk75AfIaf6GSYCNMf0= github.com/coredns/caddy v1.1.0/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= github.com/coredns/corefile-migration v1.0.21 h1:W/DCETrHDiFo0Wj03EyMkaQ9fwsmSgqTCQDHpceaSsE= @@ -197,8 +199,6 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= diff --git a/api/internal/pkg/pac-go-server/services/chat.go b/api/internal/pkg/pac-go-server/services/chat.go index 1c3c18cf..2be7630a 100644 --- a/api/internal/pkg/pac-go-server/services/chat.go +++ b/api/internal/pkg/pac-go-server/services/chat.go @@ -5,23 +5,15 @@ import ( "net/http" "time" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" "go.uber.org/zap" log "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/logger" "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" ) -var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - // Allow all origins for now - should be restricted in production - return true - }, -} - // HandleChatWebSocket handles WebSocket connections for chat func HandleChatWebSocket(c *gin.Context) { logger := log.GetLogger() @@ -38,12 +30,15 @@ func HandleChatWebSocket(c *gin.Context) { logger.Info("WebSocket connection request", zap.String("userID", userIDStr)) // Upgrade HTTP connection to WebSocket - conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + // coder/websocket uses Accept instead of Upgrade + conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, // Allow all origins for now - should be restricted in production + }) if err != nil { logger.Error("failed to upgrade to websocket", zap.Error(err)) return } - defer conn.Close() + defer conn.Close(websocket.StatusInternalError, "internal error") logger.Info("WebSocket connection established", zap.String("userID", userIDStr)) @@ -52,28 +47,32 @@ func HandleChatWebSocket(c *gin.Context) { conversationID, err := dbCon.GetNextConversationID(ctx, userIDStr) if err != nil { logger.Error("failed to get conversation ID", zap.Error(err)) - conn.WriteJSON(gin.H{"error": "Failed to initialize conversation"}) + conn.Close(websocket.StatusInternalError, "Failed to initialize conversation") return } - logger.Info("Conversation initialized", - zap.String("userID", userIDStr), + logger.Info("Conversation initialized", + zap.String("userID", userIDStr), zap.Int64("conversationID", conversationID)) // Handle incoming messages for { var incomingMsg models.ChatMessage - err := conn.ReadJSON(&incomingMsg) + err := wsjson.Read(ctx, conn, &incomingMsg) if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - logger.Error("websocket error", zap.Error(err)) + // Check if it's a normal close + closeStatus := websocket.CloseStatus(err) + if closeStatus == websocket.StatusNormalClosure || closeStatus == websocket.StatusGoingAway { + logger.Info("websocket connection closed normally", zap.String("userID", userIDStr)) + } else if closeStatus != -1 { + logger.Error("websocket error", zap.Error(err), zap.Int("closeStatus", int(closeStatus))) } else { - logger.Info("websocket connection closed", zap.String("userID", userIDStr)) + logger.Error("websocket read error", zap.Error(err)) } break } - logger.Info("Received message", + logger.Info("Received message", zap.String("userID", userIDStr), zap.String("message", incomingMsg.Message)) @@ -89,7 +88,7 @@ func HandleChatWebSocket(c *gin.Context) { // Save user message to MongoDB if err := dbCon.InsertChatMessage(userMessage); err != nil { logger.Error("failed to save user message", zap.Error(err)) - conn.WriteJSON(gin.H{"error": "Failed to save message"}) + wsjson.Write(ctx, conn, gin.H{"error": "Failed to save message"}) continue } @@ -107,7 +106,7 @@ func HandleChatWebSocket(c *gin.Context) { // Save admin response to MongoDB if err := dbCon.InsertChatMessage(adminMessage); err != nil { logger.Error("failed to save admin message", zap.Error(err)) - conn.WriteJSON(gin.H{"error": "Failed to save response"}) + wsjson.Write(ctx, conn, gin.H{"error": "Failed to save response"}) continue } @@ -121,11 +120,13 @@ func HandleChatWebSocket(c *gin.Context) { "timestamp": adminMessage.Timestamp.Format(time.RFC3339), } - if err := conn.WriteJSON(response); err != nil { + if err := wsjson.Write(ctx, conn, response); err != nil { logger.Error("failed to send response", zap.Error(err)) break } logger.Info("Response sent to client", zap.String("userID", userIDStr)) } + + conn.Close(websocket.StatusNormalClosure, "") }