diff --git a/Dockerfile b/Dockerfile index 4b4c9ce..73ddf5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ FROM alpine:3.20.1 AS prod WORKDIR /app COPY --from=build /app/main /app/main COPY --from=build /app/.env /app/.env -EXPOSE 8080 +EXPOSE 8070 CMD ["./main"] diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..cf5098f --- /dev/null +++ b/TODO.md @@ -0,0 +1,57 @@ +# WebSocket Implementation Completion TODO + +## Current Status +- [x] Analysis completed +- [x] Plan approved by user +- [ ] Implementation started + +## Implementation Tasks + +### 1. Enhance client.go message handlers +- [ ] Implement database operations (db_insert, db_query, db_custom_query) +- [ ] Implement admin functions (kick_client, kill_server, clear_logs) +- [ ] Enhance room management (join_room, leave_room, get_room_info) +- [ ] Improve ping/pong and heartbeat handling +- [ ] Add proper error handling and responses + +### 2. Create proper MessageHandler interfaces +- [ ] Create DatabaseHandler for database operations +- [ ] Create AdminHandler for admin functions +- [ ] Create RoomHandler for room management +- [ ] Create MonitoringHandler for stats/health checks +- [ ] Register all handlers in MessageRegistry + +### 3. Update broadcaster.go integration +- [ ] Ensure proper hub integration +- [ ] Add missing server-to-client broadcasting methods +- [ ] Improve message routing for different communication patterns + +### 4. Update message.go and registry usage +- [ ] Ensure all message types are defined +- [ ] Update hub to use MessageRegistry instead of direct handling +- [ ] Test registry functionality + +### 5. Update hub.go message routing +- [ ] Modify hub to route messages through registry +- [ ] Ensure proper message queuing and broadcasting +- [ ] Add monitoring for message processing + +### 6. Testing and verification +- [ ] Test broadcast messaging +- [ ] Test client-to-client direct messaging +- [ ] Test room management functionality +- [ ] Test database operations +- [ ] Test admin functions +- [ ] Verify HTML AJAX example works with all features + +## Files to be modified +- internal/services/websocket/client.go +- internal/services/websocket/message.go +- internal/services/websocket/broadcaster.go +- internal/services/websocket/hub.go + +## Notes +- All database operations are currently stubbed and return "not implemented" messages +- Admin functions need proper authentication/authorization (currently just acknowledge) +- MessageRegistry exists but is not being used in handleMessage +- Client.html already supports all required message types diff --git a/api b/api new file mode 100755 index 0000000..dd25dea Binary files /dev/null and b/api differ diff --git a/docker-compose.yml b/docker-compose.yml index 17b58e9..4355f7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,80 +1,4 @@ services: - # # PostgreSQL Database - # psql_bp: - # image: postgres:15-alpine - # restart: unless-stopped - # environment: - # POSTGRES_USER: stim - # POSTGRES_PASSWORD: stim*RS54 - # POSTGRES_DB: satu_db - # ports: - # - "5432:5432" - # volumes: - # - postgres_data:/var/lib/postgresql/data - # healthcheck: - # test: ["CMD-SHELL", "pg_isready -U stim -d satu_db"] - # interval: 10s - # timeout: 5s - # retries: 5 - # networks: - # - blueprint - - # # MongoDB Database - # mongodb: - # image: mongo:7-jammy - # restart: unless-stopped - # environment: - # MONGO_INITDB_ROOT_USERNAME: admin - # MONGO_INITDB_ROOT_PASSWORD: stim*rs54 - # ports: - # - "27017:27017" - # volumes: - # - mongodb_data:/data/db - # networks: - # - blueprint - - # # MySQL Antrian Database - # mysql_antrian: - # image: mysql:8.0 - # restart: unless-stopped - # environment: - # MYSQL_ROOT_PASSWORD: www-data - # MYSQL_USER: www-data - # MYSQL_PASSWORD: www-data - # MYSQL_DATABASE: antrian_rssa - # ports: - # - "3306:3306" - # volumes: - # - mysql_antrian_data:/var/lib/mysql - # healthcheck: - # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - # interval: 10s - # timeout: 5s - # retries: 5 - # networks: - # - blueprint - - # # MySQL Medical Database - # mysql_medical: - # image: mysql:8.0 - # restart: unless-stopped - # environment: - # MYSQL_ROOT_PASSWORD: meninjar*RS54 - # MYSQL_USER: meninjardev - # MYSQL_PASSWORD: meninjar*RS54 - # MYSQL_DATABASE: healtcare_database - # ports: - # - "3307:3306" - # volumes: - # - mysql_medical_data:/var/lib/mysql - # healthcheck: - # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - # interval: 10s - # timeout: 5s - # retries: 5 - # networks: - # - blueprint - # Main Application app: build: @@ -83,11 +7,11 @@ services: target: prod restart: unless-stopped ports: - - "8080:8080" + - "8070:8070" environment: # Server Configuration APP_ENV: production - PORT: 8080 + PORT: 8070 GIN_MODE: release # Default Database Configuration (PostgreSQL) @@ -99,48 +23,48 @@ services: DB_PORT: 5432 DB_SSLMODE: disable - # satudata Database Configuration (PostgreSQL) - POSTGRES_SATUDATA_CONNECTION: postgres - POSTGRES_SATUDATA_USERNAME: stim - POSTGRES_SATUDATA_PASSWORD: stim*RS54 - POSTGRES_SATUDATA_HOST: 10.10.123.165 - POSTGRES_SATUDATA_DATABASE: satu_db - POSTGRES_SATUDATA_PORT: 5432 - POSTGRES_SATUDATA_SSLMODE: disable + # # satudata Database Configuration (PostgreSQL) + # POSTGRES_SATUDATA_CONNECTION: postgres + # POSTGRES_SATUDATA_USERNAME: stim + # POSTGRES_SATUDATA_PASSWORD: stim*RS54 + # POSTGRES_SATUDATA_HOST: 10.10.123.165 + # POSTGRES_SATUDATA_DATABASE: satu_db + # POSTGRES_SATUDATA_PORT: 5432 + # POSTGRES_SATUDATA_SSLMODE: disable - # Mongo Database - MONGODB_MONGOHL7_CONNECTION: mongodb - MONGODB_MONGOHL7_HOST: 10.10.123.206 - MONGODB_MONGOHL7_PORT: 27017 - MONGODB_MONGOHL7_USER: admin - MONGODB_MONGOHL7_PASS: stim*rs54 - MONGODB_MONGOHL7_MASTER: master - MONGODB_MONGOHL7_LOCAL: local - MONGODB_MONGOHL7_SSLMODE: disable + # # Mongo Database + # MONGODB_MONGOHL7_CONNECTION: mongodb + # MONGODB_MONGOHL7_HOST: 10.10.123.206 + # MONGODB_MONGOHL7_PORT: 27017 + # MONGODB_MONGOHL7_USER: admin + # MONGODB_MONGOHL7_PASS: stim*rs54 + # MONGODB_MONGOHL7_MASTER: master + # MONGODB_MONGOHL7_LOCAL: local + # MONGODB_MONGOHL7_SSLMODE: disable - # MYSQL Antrian Database - # MYSQL_ANTRIAN_CONNECTION: mysql - # MYSQL_ANTRIAN_HOST: mysql_antrian - # MYSQL_ANTRIAN_USERNAME: www-data - # MYSQL_ANTRIAN_PASSWORD: www-data - # MYSQL_ANTRIAN_DATABASE: antrian_rssa - # MYSQL_ANTRIAN_PORT: 3306 - # MYSQL_ANTRIAN_SSLMODE: disable + # # MYSQL Antrian Database + # # MYSQL_ANTRIAN_CONNECTION: mysql + # # MYSQL_ANTRIAN_HOST: mysql_antrian + # # MYSQL_ANTRIAN_USERNAME: www-data + # # MYSQL_ANTRIAN_PASSWORD: www-data + # # MYSQL_ANTRIAN_DATABASE: antrian_rssa + # # MYSQL_ANTRIAN_PORT: 3306 + # # MYSQL_ANTRIAN_SSLMODE: disable - # MYSQL Medical Database - MYSQL_MEDICAL_CONNECTION: mysql - MYSQL_MEDICAL_HOST: 10.10.123.163 - MYSQL_MEDICAL_USERNAME: meninjardev - MYSQL_MEDICAL_PASSWORD: meninjar*RS54 - MYSQL_MEDICAL_DATABASE: healtcare_database - MYSQL_MEDICAL_PORT: 3306 - MYSQL_MEDICAL_SSLMODE: disable + # # MYSQL Medical Database + # MYSQL_MEDICAL_CONNECTION: mysql + # MYSQL_MEDICAL_HOST: 10.10.123.163 + # MYSQL_MEDICAL_USERNAME: meninjardev + # MYSQL_MEDICAL_PASSWORD: meninjar*RS54 + # MYSQL_MEDICAL_DATABASE: healtcare_database + # MYSQL_MEDICAL_PORT: 3306 + # MYSQL_MEDICAL_SSLMODE: disable # Keycloak Configuration KEYCLOAK_ISSUER: https://auth.rssa.top/realms/sandbox KEYCLOAK_AUDIENCE: nuxtsim-pendaftaran KEYCLOAK_JWKS_URL: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs - KEYCLOAK_ENABLED: true + KEYCLOAK_ENABLED: "true" # BPJS Configuration BPJS_BASEURL: https://apijkn.bpjs-kesehatan.go.id/vclaim-rest @@ -171,7 +95,25 @@ services: API_TITLE: API Service UJICOBA API_DESCRIPTION: Dokumentation SWAGGER API_VERSION: 3.0.0 - + # WebSocket Configuration + WS_READ_TIMEOUT: 300s + WS_WRITE_TIMEOUT: 30s + WS_PING_INTERVAL: 60s + WS_PONG_TIMEOUT: 70s + WS_HANDSHAKE_TIMEOUT: 45s + WS_READ_BUFFER_SIZE: 8192 + WS_WRITE_BUFFER_SIZE: 8192 + WS_CHANNEL_BUFFER_SIZE: 512 + WS_MESSAGE_QUEUE_SIZE: 5000 + WS_MAX_MESSAGE_SIZE: 8192 + WS_QUEUE_WORKERS: 10 + WS_ACTIVITY_LOG_SIZE: 1000 + WS_CLEANUP_INTERVAL: 2m + WS_INACTIVE_TIMEOUT: 5m + WS_SERVER_ID: api-service-v1 + WS_ENABLE_COMPRESSION: "true" + WS_ENABLE_METRICS: "true" + WS_ENABLE_MONITORING: "true" # depends_on: # psql_bp: # condition: service_healthy diff --git a/examples/clientsocket/client.html b/examples/clientsocket/client.html index e2dd860..d61fd50 100644 --- a/examples/clientsocket/client.html +++ b/examples/clientsocket/client.html @@ -821,7 +821,7 @@ ); const ipBased = document.getElementById("ipBasedCheck").checked; - let url = `ws://meninjar.dev.rssa.id:8030/api/v1/ws?user_id=${encodeURIComponent( + let url = `ws://meninjar.dev.rssa.id:8070/api/v1/ws?user_id=${encodeURIComponent( userId )}&room=${encodeURIComponent(room)}`; diff --git a/go.mod b/go.mod index 5dd4cb6..5b97679 100644 --- a/go.mod +++ b/go.mod @@ -17,17 +17,13 @@ require ( ) require ( - github.com/daku10/go-lz-string v0.0.6 github.com/go-playground/validator/v10 v10.27.0 github.com/go-sql-driver/mysql v1.8.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 - github.com/mashingan/smapping v0.1.19 - github.com/rs/zerolog v1.34.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.6 - github.com/tidwall/gjson v1.18.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -62,7 +58,6 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.6 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/microsoft/go-mssqldb v1.8.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -70,8 +65,6 @@ require ( github.com/montanaflynn/stats v0.7.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7ecaabb..d9d0ff8 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/daku10/go-lz-string v0.0.6 h1:aO8FFp4QPuNp7+WNyh1DyNjGF3UbZu95tUv9xOZNsYQ= -github.com/daku10/go-lz-string v0.0.6/go.mod h1:Vk++rSG3db8HXJaHEAbxiy/ukjTmPBw/iI+SrVZDzfs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -69,7 +66,6 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -140,12 +136,6 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mashingan/smapping v0.1.19 h1:SsEtuPn2UcM1croIupPtGLgWgpYRuS0rSQMvKD9g2BQ= -github.com/mashingan/smapping v0.1.19/go.mod h1:FjfiwFxGOuNxL/OT1WcrNAwTPx0YJeg5JiXwBB1nyig= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= @@ -166,16 +156,12 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -197,12 +183,6 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -278,7 +258,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/config/config.go b/internal/config/config.go index f34deb4..6a864ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { Bpjs BpjsConfig SatuSehat SatuSehatConfig Swagger SwaggerConfig + WebSocket WebSocketConfig // Tambahkan ini Validator *validator.Validate } @@ -90,6 +91,38 @@ type SatuSehatConfig struct { Timeout time.Duration `json:"timeout"` } +type WebSocketConfig struct { + // Timeout configurations + ReadTimeout time.Duration `json:"read_timeout"` + WriteTimeout time.Duration `json:"write_timeout"` + PingInterval time.Duration `json:"ping_interval"` + PongTimeout time.Duration `json:"pong_timeout"` + HandshakeTimeout time.Duration `json:"handshake_timeout"` + + // Buffer sizes + ReadBufferSize int `json:"read_buffer_size"` + WriteBufferSize int `json:"write_buffer_size"` + ChannelBufferSize int `json:"channel_buffer_size"` + MessageQueueSize int `json:"message_queue_size"` + + // Connection limits + MaxMessageSize int `json:"max_message_size"` + QueueWorkers int `json:"queue_workers"` + + // Monitoring + ActivityLogSize int `json:"activity_log_size"` + CleanupInterval time.Duration `json:"cleanup_interval"` + InactiveTimeout time.Duration `json:"inactive_timeout"` + + // Server info + ServerID string `json:"server_id"` + + // Features + EnableCompression bool `json:"enable_compression"` + EnableMetrics bool `json:"enable_metrics"` + EnableMonitoring bool `json:"enable_monitoring"` +} + // SetHeader generates required headers for BPJS VClaim API // func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) { // timenow := time.Now().UTC() @@ -150,18 +183,23 @@ func (cfg ConfigBpjs) SetHeader() (string, string, string, string, string) { func LoadConfig() *Config { config := &Config{ + // Configuration for the server Server: ServerConfig{ Port: getEnvAsInt("PORT", 8080), Mode: getEnv("GIN_MODE", "debug"), }, - Databases: make(map[string]DatabaseConfig), + // Configuration for the database + Databases: make(map[string]DatabaseConfig), + // Configuration for read replicas ReadReplicas: make(map[string][]DatabaseConfig), + // Configuration for Keycloak authentication and authorization Keycloak: KeycloakConfig{ Issuer: getEnv("KEYCLOAK_ISSUER", "https://keycloak.example.com/auth/realms/yourrealm"), Audience: getEnv("KEYCLOAK_AUDIENCE", "your-client-id"), JwksURL: getEnv("KEYCLOAK_JWKS_URL", "https://keycloak.example.com/auth/realms/yourrealm/protocol/openid-connect/certs"), Enabled: getEnvAsBool("KEYCLOAK_ENABLED", true), }, + // Configuration for BPJS service bridging API Bpjs: BpjsConfig{ BaseURL: getEnv("BPJS_BASEURL", "https://apijkn.bpjs-kesehatan.go.id"), ConsID: getEnv("BPJS_CONSID", ""), @@ -169,6 +207,7 @@ func LoadConfig() *Config { SecretKey: getEnv("BPJS_SECRETKEY", ""), Timeout: parseDuration(getEnv("BPJS_TIMEOUT", "30s")), }, + // Configuration for Satu Sehat service bridging API SatuSehat: SatuSehatConfig{ OrgID: getEnv("BRIDGING_SATUSEHAT_ORG_ID", ""), FasyakesID: getEnv("BRIDGING_SATUSEHAT_FASYAKES_ID", ""), @@ -180,6 +219,39 @@ func LoadConfig() *Config { KFAURL: getEnv("BRIDGING_SATUSEHAT_KFA_URL", "https://api-satusehat.kemkes.go.id/kfa-v2"), Timeout: parseDuration(getEnv("BRIDGING_SATUSEHAT_TIMEOUT", "30s")), }, + // Configuration for WebSocket server + WebSocket: WebSocketConfig{ // Tambahkan ini + // Timeout configurations + ReadTimeout: parseDuration(getEnv("WS_READ_TIMEOUT", "300s")), + WriteTimeout: parseDuration(getEnv("WS_WRITE_TIMEOUT", "30s")), + PingInterval: parseDuration(getEnv("WS_PING_INTERVAL", "60s")), + PongTimeout: parseDuration(getEnv("WS_PONG_TIMEOUT", "70s")), + HandshakeTimeout: parseDuration(getEnv("WS_HANDSHAKE_TIMEOUT", "45s")), + + // Buffer sizes + ReadBufferSize: getEnvAsInt("WS_READ_BUFFER_SIZE", 8192), + WriteBufferSize: getEnvAsInt("WS_WRITE_BUFFER_SIZE", 8192), + ChannelBufferSize: getEnvAsInt("WS_CHANNEL_BUFFER_SIZE", 512), + MessageQueueSize: getEnvAsInt("WS_MESSAGE_QUEUE_SIZE", 5000), + + // Connection limits + MaxMessageSize: getEnvAsInt("WS_MAX_MESSAGE_SIZE", 8192), + QueueWorkers: getEnvAsInt("WS_QUEUE_WORKERS", 10), + + // Monitoring + ActivityLogSize: getEnvAsInt("WS_ACTIVITY_LOG_SIZE", 1000), + CleanupInterval: parseDuration(getEnv("WS_CLEANUP_INTERVAL", "2m")), + InactiveTimeout: parseDuration(getEnv("WS_INACTIVE_TIMEOUT", "5m")), + + // Server info + ServerID: getEnv("WS_SERVER_ID", "api-service-v1"), + + // Features + EnableCompression: getEnvAsBool("WS_ENABLE_COMPRESSION", true), + EnableMetrics: getEnvAsBool("WS_ENABLE_METRICS", true), + EnableMonitoring: getEnvAsBool("WS_ENABLE_MONITORING", true), + }, + // Configuration for Swagger Swagger: SwaggerConfig{ Title: getEnv("SWAGGER_TITLE", "SERVICE API"), Description: getEnv("SWAGGER_DESCRIPTION", "CUSTUM SERVICE API"), @@ -735,5 +807,193 @@ func (c *Config) Validate() error { log.Fatal("SatuSehat Base URL is required") } + // Validate WebSocket configuration + if c.WebSocket.ReadTimeout <= 0 { + log.Fatal("WebSocket Read Timeout must be greater than 0") + } + if c.WebSocket.WriteTimeout <= 0 { + log.Fatal("WebSocket Write Timeout must be greater than 0") + } + if c.WebSocket.PingInterval <= 0 { + log.Fatal("WebSocket Ping Interval must be greater than 0") + } + if c.WebSocket.PongTimeout <= 0 { + log.Fatal("WebSocket Pong Timeout must be greater than 0") + } + if c.WebSocket.ReadBufferSize <= 0 { + log.Fatal("WebSocket Read Buffer Size must be greater than 0") + } + if c.WebSocket.WriteBufferSize <= 0 { + log.Fatal("WebSocket Write Buffer Size must be greater than 0") + } + if c.WebSocket.ChannelBufferSize <= 0 { + log.Fatal("WebSocket Channel Buffer Size must be greater than 0") + } + if c.WebSocket.MessageQueueSize <= 0 { + log.Fatal("WebSocket Message Queue Size must be greater than 0") + } + if c.WebSocket.MaxMessageSize <= 0 { + log.Fatal("WebSocket Max Message Size must be greater than 0") + } + if c.WebSocket.QueueWorkers <= 0 { + log.Fatal("WebSocket Queue Workers must be greater than 0") + } + if c.WebSocket.ActivityLogSize <= 0 { + log.Fatal("WebSocket Activity Log Size must be greater than 0") + } + if c.WebSocket.CleanupInterval <= 0 { + log.Fatal("WebSocket Cleanup Interval must be greater than 0") + } + if c.WebSocket.InactiveTimeout <= 0 { + log.Fatal("WebSocket Inactive Timeout must be greater than 0") + } + if c.WebSocket.ServerID == "" { + log.Fatal("WebSocket Server ID is required") + } + return nil } + +//** WebSocket **// + +// DefaultWebSocketConfig mengembalikan konfigurasi default untuk WebSocket +func DefaultWebSocketConfig() WebSocketConfig { + return WebSocketConfig{ + // Timeout configurations + ReadTimeout: 300 * time.Second, + WriteTimeout: 30 * time.Second, + PingInterval: 60 * time.Second, + PongTimeout: 70 * time.Second, + HandshakeTimeout: 45 * time.Second, + + // Buffer sizes + ReadBufferSize: 8192, + WriteBufferSize: 8192, + ChannelBufferSize: 512, + MessageQueueSize: 5000, + + // Connection limits + MaxMessageSize: 8192, + QueueWorkers: 10, + + // Monitoring + ActivityLogSize: 1000, + CleanupInterval: 2 * time.Minute, + InactiveTimeout: 5 * time.Minute, + + // Server info + ServerID: "api-service-v1", + + // Features + EnableCompression: true, + EnableMetrics: true, + EnableMonitoring: true, + } +} + +// HighPerformanceWebSocketConfig mengembalikan konfigurasi untuk performa tinggi +func HighPerformanceWebSocketConfig() WebSocketConfig { + return WebSocketConfig{ + // Timeout configurations + ReadTimeout: 300 * time.Second, + WriteTimeout: 30 * time.Second, + PingInterval: 30 * time.Second, + PongTimeout: 40 * time.Second, + HandshakeTimeout: 30 * time.Second, + + // Buffer sizes + ReadBufferSize: 16384, + WriteBufferSize: 16384, + ChannelBufferSize: 1024, + MessageQueueSize: 10000, + + // Connection limits + MaxMessageSize: 16384, + QueueWorkers: 20, + + // Monitoring + ActivityLogSize: 2000, + CleanupInterval: 1 * time.Minute, + InactiveTimeout: 3 * time.Minute, + + // Server info + ServerID: "api-service-hp", + + // Features + EnableCompression: true, + EnableMetrics: true, + EnableMonitoring: true, + } +} + +// LowResourceWebSocketConfig mengembalikan konfigurasi untuk sumber daya terbatas +func LowResourceWebSocketConfig() WebSocketConfig { + return WebSocketConfig{ + // Timeout configurations + ReadTimeout: 300 * time.Second, + WriteTimeout: 30 * time.Second, + PingInterval: 120 * time.Second, + PongTimeout: 130 * time.Second, + HandshakeTimeout: 60 * time.Second, + + // Buffer sizes + ReadBufferSize: 4096, + WriteBufferSize: 4096, + ChannelBufferSize: 256, + MessageQueueSize: 2500, + + // Connection limits + MaxMessageSize: 4096, + QueueWorkers: 5, + + // Monitoring + ActivityLogSize: 500, + CleanupInterval: 5 * time.Minute, + InactiveTimeout: 10 * time.Minute, + + // Server info + ServerID: "api-service-lr", + + // Features + EnableCompression: false, + EnableMetrics: false, + EnableMonitoring: true, + } +} + +// CustomWebSocketConfig memungkinkan kustomisasi konfigurasi +func CustomWebSocketConfig( + readTimeout, writeTimeout, pingInterval, pongTimeout time.Duration, + readBufferSize, writeBufferSize, channelBufferSize, messageQueueSize int, + maxMessageSize, queueWorkers int, + activityLogSize int, + cleanupInterval, inactiveTimeout time.Duration, + serverID string, + enableCompression, enableMetrics, enableMonitoring bool, +) WebSocketConfig { + return WebSocketConfig{ + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + PingInterval: pingInterval, + PongTimeout: pongTimeout, + HandshakeTimeout: 45 * time.Second, + + ReadBufferSize: readBufferSize, + WriteBufferSize: writeBufferSize, + ChannelBufferSize: channelBufferSize, + MessageQueueSize: messageQueueSize, + + MaxMessageSize: maxMessageSize, + QueueWorkers: queueWorkers, + + ActivityLogSize: activityLogSize, + CleanupInterval: cleanupInterval, + InactiveTimeout: inactiveTimeout, + + ServerID: serverID, + + EnableCompression: enableCompression, + EnableMetrics: enableMetrics, + EnableMonitoring: enableMonitoring, + } +} diff --git a/internal/handlers/websocket/broadcast.go b/internal/handlers/websocket/broadcast.go deleted file mode 100644 index abac19b..0000000 --- a/internal/handlers/websocket/broadcast.go +++ /dev/null @@ -1,111 +0,0 @@ -package websocket - -import ( - "sync" - "time" -) - -// WebSocketBroadcaster defines the interface for broadcasting messages -type WebSocketBroadcaster interface { - BroadcastMessage(messageType string, data interface{}) -} - -// Broadcaster handles server-initiated broadcasts to WebSocket clients -type Broadcaster struct { - handler WebSocketBroadcaster - tickers []*time.Ticker - quit chan struct{} - mu sync.Mutex -} - -// NewBroadcaster creates a new Broadcaster instance -func NewBroadcaster(handler WebSocketBroadcaster) *Broadcaster { - return &Broadcaster{ - handler: handler, - tickers: make([]*time.Ticker, 0), - quit: make(chan struct{}), - } -} - -// StartHeartbeat starts sending periodic heartbeat messages to all clients -func (b *Broadcaster) StartHeartbeat(interval time.Duration) { - ticker := time.NewTicker(interval) - b.tickers = append(b.tickers, ticker) - go func() { - defer func() { - // Remove ticker from slice when done - for i, t := range b.tickers { - if t == ticker { - b.tickers = append(b.tickers[:i], b.tickers[i+1:]...) - break - } - } - }() - for { - select { - case <-ticker.C: - b.handler.BroadcastMessage("heartbeat", map[string]interface{}{ - "message": "Server heartbeat", - "timestamp": time.Now().Format(time.RFC3339), - }) - case <-b.quit: - ticker.Stop() - return - } - } - }() -} - -// Stop stops the broadcaster -func (b *Broadcaster) Stop() { - close(b.quit) - for _, ticker := range b.tickers { - if ticker != nil { - ticker.Stop() - } - } - b.tickers = nil -} - -// BroadcastNotification sends a notification message to all clients -func (b *Broadcaster) BroadcastNotification(title, message, level string) { - b.handler.BroadcastMessage("notification", map[string]interface{}{ - "title": title, - "message": message, - "level": level, - "time": time.Now().Format(time.RFC3339), - }) -} - -// SimulateDataStream simulates streaming data to clients (useful for demos) -func (b *Broadcaster) SimulateDataStream() { - ticker := time.NewTicker(100 * time.Millisecond) - b.tickers = append(b.tickers, ticker) - go func() { - defer func() { - // Remove ticker from slice when done - for i, t := range b.tickers { - if t == ticker { - b.tickers = append(b.tickers[:i], b.tickers[i+1:]...) - break - } - } - }() - counter := 0 - for { - select { - case <-ticker.C: - counter++ - b.handler.BroadcastMessage("data_stream", map[string]interface{}{ - "id": counter, - "value": counter * 10, - "timestamp": time.Now().Format(time.RFC3339), - "type": "simulated_data", - }) - case <-b.quit: - ticker.Stop() - return - } - } - }() -} diff --git a/internal/handlers/websocket/broadcast_test.go b/internal/handlers/websocket/broadcast_test.go deleted file mode 100644 index 4f129cf..0000000 --- a/internal/handlers/websocket/broadcast_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package websocket - -import ( - "sync" - "testing" - "time" -) - -// MockWebSocketHandler is a mock implementation for testing -type MockWebSocketHandler struct { - mu sync.Mutex - messages []map[string]interface{} - broadcasts []string -} - -func (m *MockWebSocketHandler) BroadcastMessage(messageType string, data interface{}) { - m.mu.Lock() - defer m.mu.Unlock() - m.broadcasts = append(m.broadcasts, messageType) - m.messages = append(m.messages, map[string]interface{}{ - "type": messageType, - "data": data, - }) -} - -func (m *MockWebSocketHandler) GetMessages() []map[string]interface{} { - m.mu.Lock() - defer m.mu.Unlock() - result := make([]map[string]interface{}, len(m.messages)) - copy(result, m.messages) - return result -} - -func (m *MockWebSocketHandler) GetBroadcasts() []string { - m.mu.Lock() - defer m.mu.Unlock() - result := make([]string, len(m.broadcasts)) - copy(result, m.broadcasts) - return result -} - -func (m *MockWebSocketHandler) Clear() { - m.mu.Lock() - defer m.mu.Unlock() - m.messages = make([]map[string]interface{}, 0) - m.broadcasts = make([]string, 0) -} - -func NewMockWebSocketHandler() *MockWebSocketHandler { - return &MockWebSocketHandler{ - messages: make([]map[string]interface{}, 0), - broadcasts: make([]string, 0), - } -} - -func TestBroadcaster_StartHeartbeat(t *testing.T) { - mockHandler := NewMockWebSocketHandler() - broadcaster := NewBroadcaster(mockHandler) - - // Start heartbeat with short interval for testing - broadcaster.StartHeartbeat(100 * time.Millisecond) - - // Wait for a few heartbeats - time.Sleep(350 * time.Millisecond) - - // Stop the broadcaster - broadcaster.Stop() - - // Check if heartbeats were sent - messages := mockHandler.GetMessages() - if len(messages) == 0 { - t.Error("Expected heartbeat messages, but got none") - } - - // Check that all messages are heartbeat type - broadcasts := mockHandler.GetBroadcasts() - for _, msgType := range broadcasts { - if msgType != "heartbeat" { - t.Errorf("Expected heartbeat message type, got %s", msgType) - } - } - - t.Logf("Received %d heartbeat messages", len(messages)) -} - -func TestBroadcaster_BroadcastNotification(t *testing.T) { - mockHandler := NewMockWebSocketHandler() - broadcaster := NewBroadcaster(mockHandler) - - // Send a notification - broadcaster.BroadcastNotification("Test Title", "Test Message", "info") - - // Check if notification was sent - messages := mockHandler.GetMessages() - if len(messages) != 1 { - t.Errorf("Expected 1 message, got %d", len(messages)) - return - } - - msg := messages[0] - if msg["type"] != "notification" { - t.Errorf("Expected message type 'notification', got %s", msg["type"]) - } - - data := msg["data"].(map[string]interface{}) - if data["title"] != "Test Title" { - t.Errorf("Expected title 'Test Title', got %s", data["title"]) - } - if data["message"] != "Test Message" { - t.Errorf("Expected message 'Test Message', got %s", data["message"]) - } - if data["level"] != "info" { - t.Errorf("Expected level 'info', got %s", data["level"]) - } - - t.Logf("Notification sent successfully: %+v", data) -} - -func TestBroadcaster_SimulateDataStream(t *testing.T) { - mockHandler := NewMockWebSocketHandler() - broadcaster := NewBroadcaster(mockHandler) - - // Start data stream with short interval for testing - broadcaster.SimulateDataStream() - - // Wait for a few data points - time.Sleep(550 * time.Millisecond) - - // Stop the broadcaster - broadcaster.Stop() - - // Check if data stream messages were sent - messages := mockHandler.GetMessages() - if len(messages) == 0 { - t.Error("Expected data stream messages, but got none") - } - - // Check that all messages are data_stream type - broadcasts := mockHandler.GetBroadcasts() - for _, msgType := range broadcasts { - if msgType != "data_stream" { - t.Errorf("Expected data_stream message type, got %s", msgType) - } - } - - // Check data structure - for i, msg := range messages { - data := msg["data"].(map[string]interface{}) - if data["type"] != "simulated_data" { - t.Errorf("Expected data type 'simulated_data', got %s", data["type"]) - } - if id, ok := data["id"].(int); ok { - if id != i+1 { - t.Errorf("Expected id %d, got %d", i+1, id) - } - } - if value, ok := data["value"].(int); ok { - expectedValue := (i + 1) * 10 - if value != expectedValue { - t.Errorf("Expected value %d, got %d", expectedValue, value) - } - } - } - - t.Logf("Received %d data stream messages", len(messages)) -} - -func TestBroadcaster_Stop(t *testing.T) { - mockHandler := NewMockWebSocketHandler() - broadcaster := NewBroadcaster(mockHandler) - - // Start heartbeat - broadcaster.StartHeartbeat(50 * time.Millisecond) - - // Wait a bit - time.Sleep(100 * time.Millisecond) - - // Stop the broadcaster - broadcaster.Stop() - - // Clear previous messages - mockHandler.Clear() - - // Wait a bit more to ensure no new messages are sent - time.Sleep(200 * time.Millisecond) - - // Check that no new messages were sent after stopping - messages := mockHandler.GetMessages() - if len(messages) > 0 { - t.Errorf("Expected no messages after stopping, but got %d", len(messages)) - } - - // Clear quit channel to allow reuse in tests - broadcaster.quit = make(chan struct{}) - - t.Log("Broadcaster stopped successfully") -} - -func TestBroadcaster_MultipleOperations(t *testing.T) { - mockHandler := NewMockWebSocketHandler() - broadcaster := NewBroadcaster(mockHandler) - - // Start heartbeat - broadcaster.StartHeartbeat(100 * time.Millisecond) - - // Send notification - broadcaster.BroadcastNotification("Test", "Message", "warning") - - // Start data stream - broadcaster.SimulateDataStream() - - // Wait for some activity - time.Sleep(350 * time.Millisecond) - - // Stop everything - broadcaster.Stop() - - // Check results - messages := mockHandler.GetMessages() - if len(messages) == 0 { - t.Error("Expected messages from multiple operations, but got none") - } - - broadcasts := mockHandler.GetBroadcasts() - hasHeartbeat := false - hasNotification := false - hasDataStream := false - - for _, msgType := range broadcasts { - switch msgType { - case "heartbeat": - hasHeartbeat = true - case "notification": - hasNotification = true - case "data_stream": - hasDataStream = true - } - } - - if !hasHeartbeat { - t.Error("Expected heartbeat messages") - } - if !hasNotification { - t.Error("Expected notification message") - } - if !hasDataStream { - t.Error("Expected data stream messages") - } - - t.Logf("Multiple operations test passed: %d total messages", len(messages)) -} diff --git a/internal/handlers/websocket/websocket.go b/internal/handlers/websocket/websocket.go index 1338019..757d4bf 100644 --- a/internal/handlers/websocket/websocket.go +++ b/internal/handlers/websocket/websocket.go @@ -1,1621 +1,136 @@ package websocket import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" + "api-service/internal/config" + ws "api-service/internal/services/websocket" "fmt" - "net" "net/http" - "strings" - "sync" "time" - "api-service/internal/config" - "api-service/internal/database" - "api-service/pkg/logger" - "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/gorilla/websocket" ) -const ( - // Timeout configurations (diperpanjang untuk stability) - ReadTimeout = 300 * time.Second // 5 menit (diperpanjang dari 60 detik) - WriteTimeout = 30 * time.Second // 30 detik untuk write operations - PingInterval = 60 * time.Second // 1 menit untuk ping (lebih konservatif) - PongTimeout = 70 * time.Second // 70 detik untuk menunggu pong response - - // Buffer sizes - ReadBufferSize = 8192 // Diperbesar untuk pesan besar - WriteBufferSize = 8192 // Diperbesar untuk pesan besar - ChannelBufferSize = 512 // Buffer untuk channel komunikasi - - // Connection limits - MaxMessageSize = 8192 // Maksimum ukuran pesan - HandshakeTimeout = 45 * time.Second -) - -type WebSocketMessage struct { - Type string `json:"type"` - Data interface{} `json:"data"` - Timestamp time.Time `json:"timestamp"` - ClientID string `json:"client_id,omitempty"` - MessageID string `json:"message_id,omitempty"` -} - -type Client struct { - ID string - StaticID string // Static ID for persistent identification - IPAddress string // Client IP address - Conn *websocket.Conn - Send chan WebSocketMessage - Hub *Hub - UserID string - Room string - ctx context.Context - cancel context.CancelFunc - lastPing time.Time - lastPong time.Time - connectedAt time.Time - mu sync.RWMutex - isActive bool -} - -type ClientInfo struct { - ID string `json:"id"` - StaticID string `json:"static_id"` - IPAddress string `json:"ip_address"` - UserID string `json:"user_id"` - Room string `json:"room"` - ConnectedAt time.Time `json:"connected_at"` - LastPing time.Time `json:"last_ping"` - LastPong time.Time `json:"last_pong"` - IsActive bool `json:"is_active"` -} -type DetailedStats struct { - ConnectedClients int `json:"connected_clients"` - UniqueIPs int `json:"unique_ips"` - StaticClients int `json:"static_clients"` - ActiveRooms int `json:"active_rooms"` - IPDistribution map[string]int `json:"ip_distribution"` - RoomDistribution map[string]int `json:"room_distribution"` - MessageQueueSize int `json:"message_queue_size"` - QueueWorkers int `json:"queue_workers"` - Uptime time.Duration `json:"uptime_seconds"` - Timestamp int64 `json:"timestamp"` -} - -type MonitoringData struct { - Stats DetailedStats `json:"stats"` - RecentActivity []ActivityLog `json:"recent_activity"` - SystemHealth map[string]interface{} `json:"system_health"` - Performance PerformanceMetrics `json:"performance"` -} - -type ActivityLog struct { - Timestamp time.Time `json:"timestamp"` - Event string `json:"event"` - ClientID string `json:"client_id"` - Details string `json:"details"` -} - -type PerformanceMetrics struct { - MessagesPerSecond float64 `json:"messages_per_second"` - AverageLatency float64 `json:"average_latency_ms"` - ErrorRate float64 `json:"error_rate_percent"` - MemoryUsage int64 `json:"memory_usage_bytes"` -} - -// Tambahkan field untuk monitoring di Hub -type Hub struct { - clients map[*Client]bool - clientsByID map[string]*Client // Track clients by ID - clientsByIP map[string][]*Client // Track clients by IP - clientsByStatic map[string]*Client // Track clients by static ID - broadcast chan WebSocketMessage - register chan *Client - unregister chan *Client - rooms map[string]map[*Client]bool - mu sync.RWMutex - ctx context.Context - cancel context.CancelFunc - dbService database.Service - messageQueue chan WebSocketMessage - queueWorkers int - - // Monitoring fields - startTime time.Time - messageCount int64 - errorCount int64 - activityLog []ActivityLog - activityMu sync.RWMutex -} - +// WebSocketHandler menangani koneksi WebSocket type WebSocketHandler struct { - hub *Hub - logger *logger.Logger - upgrader websocket.Upgrader - config *config.Config - dbService database.Service - primaryDB string + hub *ws.Hub + config config.WebSocketConfig + upgrader websocket.Upgrader + monitoringManager *ws.MonitoringManager } -func NewWebSocketHandler(cfg *config.Config, dbService database.Service) *WebSocketHandler { - ctx, cancel := context.WithCancel(context.Background()) - - hub := &Hub{ - clients: make(map[*Client]bool), - clientsByID: make(map[string]*Client), - clientsByIP: make(map[string][]*Client), - clientsByStatic: make(map[string]*Client), - broadcast: make(chan WebSocketMessage, 1000), - register: make(chan *Client), - unregister: make(chan *Client), - rooms: make(map[string]map[*Client]bool), - ctx: ctx, - cancel: cancel, - dbService: dbService, - messageQueue: make(chan WebSocketMessage, 5000), - queueWorkers: 10, - startTime: time.Now(), - activityLog: make([]ActivityLog, 0, 1000), // Keep last 1000 activities - } - - handler := &WebSocketHandler{ - hub: hub, - logger: logger.Default(), - config: cfg, - dbService: dbService, - primaryDB: "default", +// NewWebSocketHandler membuat handler WebSocket baru +func NewWebSocketHandler(hub *ws.Hub, config config.WebSocketConfig) *WebSocketHandler { + return &WebSocketHandler{ + hub: hub, + config: config, + monitoringManager: ws.NewMonitoringManager(hub), upgrader: websocket.Upgrader{ + ReadBufferSize: config.ReadBufferSize, + WriteBufferSize: config.WriteBufferSize, CheckOrigin: func(r *http.Request) bool { + // Dalam production, implementasikan validasi origin yang proper return true }, - ReadBufferSize: ReadBufferSize, // Gunakan konstanta - WriteBufferSize: WriteBufferSize, // Gunakan konstanta - HandshakeTimeout: HandshakeTimeout, // Gunakan konstanta - EnableCompression: true, }, } - - // Start hub and services - go hub.Run() - go handler.StartDatabaseListener() - // go handler.StartServerBroadcasters() - go handler.StartMessageQueue() - go handler.StartConnectionMonitor() - - return handler -} - -// Helper function to get client IP address -func getClientIP(c *gin.Context) string { - // Check for X-Forwarded-For header (proxy/load balancer) - xff := c.GetHeader("X-Forwarded-For") - if xff != "" { - ips := strings.Split(xff, ",") - if len(ips) > 0 { - return strings.TrimSpace(ips[0]) - } - } - - // Check for X-Real-IP header (nginx proxy) - xri := c.GetHeader("X-Real-IP") - if xri != "" { - return strings.TrimSpace(xri) - } - - // Get IP from RemoteAddr - ip, _, err := net.SplitHostPort(c.Request.RemoteAddr) - if err != nil { - return c.Request.RemoteAddr - } - - return ip -} - -// Generate static client ID based on IP and optional static ID -func generateClientID(ipAddress, staticID, userID string) string { - if staticID != "" { - // Use provided static ID - return staticID - } - - // Generate ID based on IP and userID - data := fmt.Sprintf("%s:%s:%d", ipAddress, userID, time.Now().Unix()/3600) // Hour-based for some uniqueness - hash := sha256.Sum256([]byte(data)) - return fmt.Sprintf("client_%s", hex.EncodeToString(hash[:8])) // Use first 8 bytes of hash -} - -// Generate IP-based static ID -func generateIPBasedID(ipAddress string) string { - hash := sha256.Sum256([]byte(ipAddress)) - return fmt.Sprintf("ip_%s", hex.EncodeToString(hash[:6])) // Use first 6 bytes of hash } +// HandleWebSocket menangani upgrade koneksi ke WebSocket func (h *WebSocketHandler) HandleWebSocket(c *gin.Context) { + // Ambil parameter dari query atau header + clientID := c.Query("client_id") + if clientID == "" { + clientID = fmt.Sprintf("client_%d", time.Now().UnixNano()) + } + + staticID := c.Query("static_id") + userID := c.Query("user_id") + room := c.Query("room") + ipAddress := c.ClientIP() + + // Upgrade koneksi HTTP ke WebSocket conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - h.logger.Error(fmt.Sprintf("Failed to upgrade connection: %v", err)) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Failed to upgrade connection", + "details": err.Error(), + }) return } - // Get connection parameters - userID := c.Query("user_id") - if userID == "" { - userID = "anonymous" - } + // Buat klien WebSocket baru + client := ws.NewClient(clientID, staticID, userID, room, ipAddress, conn, h.hub, h.config) - room := c.Query("room") - if room == "" { - room = "default" - } + // Daftarkan klien ke hub + h.hub.RegisterChannel() <- client - staticID := c.Query("static_id") // Optional static ID - useIPBasedID := c.Query("ip_based") // Use IP-based ID if "true" + // Jalankan goroutine untuk membaca dan menulis + go client.ReadPump() + go client.WritePump() +} - // Get client IP address - ipAddress := getClientIP(c) +// GetWebSocketStats mengembalikan statistik WebSocket +func (h *WebSocketHandler) GetWebSocketStats(c *gin.Context) { + stats := h.hub.GetStats() + c.JSON(http.StatusOK, stats) +} - var clientID string - - // Determine client ID generation strategy - if useIPBasedID == "true" { - clientID = generateIPBasedID(ipAddress) - staticID = clientID - } else if staticID != "" { - clientID = staticID - } else { - clientID = generateClientID(ipAddress, staticID, userID) - } - - // Check if client with same static ID already exists - h.hub.mu.Lock() - if existingClient, exists := h.hub.clientsByStatic[clientID]; exists { - h.logger.Info(fmt.Sprintf("Disconnecting existing client %s for reconnection", clientID)) - // Disconnect existing client - existingClient.cancel() - existingClient.Conn.Close() - delete(h.hub.clientsByStatic, clientID) - } - h.hub.mu.Unlock() - - ctx, cancel := context.WithCancel(h.hub.ctx) - - client := &Client{ - ID: clientID, - StaticID: clientID, - IPAddress: ipAddress, - Conn: conn, - Send: make(chan WebSocketMessage, 256), - Hub: h.hub, - UserID: userID, - Room: room, - ctx: ctx, - cancel: cancel, - lastPing: time.Now(), - connectedAt: time.Now(), - } - - client.Hub.register <- client - - // Send welcome message with connection info - welcomeMsg := WebSocketMessage{ - Type: "welcome", - Data: map[string]interface{}{ - "message": "Connected to WebSocket server", - "client_id": client.ID, - "static_id": client.StaticID, - "ip_address": client.IPAddress, - "room": client.Room, - "user_id": client.UserID, - "connected_at": client.connectedAt.Unix(), - "id_type": h.getIDType(useIPBasedID, staticID), +// TestWebSocketConnection endpoint untuk test koneksi WebSocket +func (h *WebSocketHandler) TestWebSocketConnection(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "WebSocket service is running", + "status": "active", + "endpoint": "/api/v1/ws", + "parameters": gin.H{ + "client_id": "optional, auto-generated if not provided", + "static_id": "optional, for persistent client identification", + "user_id": "optional, for user identification", + "room": "optional, for room-based messaging", }, - Timestamp: time.Now(), - MessageID: uuid.New().String(), - } - - select { - case client.Send <- welcomeMsg: - default: - close(client.Send) - cancel() - return - } - - go client.writePump() - go client.readPump() -} - -func (h *WebSocketHandler) getIDType(useIPBased, staticID string) string { - if useIPBased == "true" { - return "ip_based" - } else if staticID != "" { - return "static" - } - return "generated" -} - -func (h *Hub) Run() { - for { - select { - case client := <-h.register: - h.mu.Lock() - - // Register in main clients map - h.clients[client] = true - - // Register by ID - h.clientsByID[client.ID] = client - - // Register by static ID - if client.StaticID != "" { - h.clientsByStatic[client.StaticID] = client - } - - // Register by IP - if h.clientsByIP[client.IPAddress] == nil { - h.clientsByIP[client.IPAddress] = make([]*Client, 0) - } - h.clientsByIP[client.IPAddress] = append(h.clientsByIP[client.IPAddress], client) - - // Register in room - if client.Room != "" { - if h.rooms[client.Room] == nil { - h.rooms[client.Room] = make(map[*Client]bool) - } - h.rooms[client.Room][client] = true - } - - h.mu.Unlock() - - // Log activity - h.logActivity("client_connected", client.ID, - fmt.Sprintf("IP: %s, Static: %s, Room: %s", client.IPAddress, client.StaticID, client.Room)) - - logger.Info(fmt.Sprintf("Client %s (Static: %s, IP: %s) connected to room %s", - client.ID, client.StaticID, client.IPAddress, client.Room)) - - case client := <-h.unregister: - h.mu.Lock() - - if _, ok := h.clients[client]; ok { - // Remove from main clients - delete(h.clients, client) - close(client.Send) - - // Remove from clientsByID - delete(h.clientsByID, client.ID) - - // Remove from clientsByStatic - if client.StaticID != "" { - delete(h.clientsByStatic, client.StaticID) - } - - // Remove from clientsByIP - if ipClients, exists := h.clientsByIP[client.IPAddress]; exists { - for i, c := range ipClients { - if c == client { - h.clientsByIP[client.IPAddress] = append(ipClients[:i], ipClients[i+1:]...) - break - } - } - // If no more clients from this IP, remove the IP entry - if len(h.clientsByIP[client.IPAddress]) == 0 { - delete(h.clientsByIP, client.IPAddress) - } - } - - // Remove from room - if client.Room != "" { - if room, exists := h.rooms[client.Room]; exists { - delete(room, client) - if len(room) == 0 { - delete(h.rooms, client.Room) - } - } - } - } - - h.mu.Unlock() - - // Log activity - h.logActivity("client_disconnected", client.ID, - fmt.Sprintf("IP: %s, Duration: %v", client.IPAddress, time.Since(client.connectedAt))) - - client.cancel() - logger.Info(fmt.Sprintf("Client %s (IP: %s) disconnected", client.ID, client.IPAddress)) - - case message := <-h.broadcast: - h.messageCount++ - h.broadcastToClients(message) - - case <-h.ctx.Done(): - return - } - } -} - -// Enhanced message handling with client info -func (c *Client) handleMessage(msg WebSocketMessage) { - switch msg.Type { - case "ping": - // Respons ping dari client dengan informasi lebih lengkap - c.sendDirectResponse("pong", map[string]interface{}{ - "message": "Server is alive", - "timestamp": time.Now().Unix(), - "client_id": c.ID, - "static_id": c.StaticID, - "server_time": time.Now().Format(time.RFC3339), - "uptime": time.Since(c.connectedAt).Seconds(), - }) - - case "heartbeat": - // Tambahan: Handle heartbeat khusus - c.sendDirectResponse("heartbeat_ack", map[string]interface{}{ - "client_id": c.ID, - "timestamp": time.Now().Unix(), - "status": "alive", - }) - - case "connection_test": - // Tambahan: Test koneksi - c.sendDirectResponse("connection_test_result", map[string]interface{}{ - "latency_ms": 0, // Bisa dihitung jika perlu - "connection_id": c.ID, - "is_active": c.isClientActive(), - "last_ping": c.lastPing.Unix(), - "last_pong": c.lastPong.Unix(), - }) - - case "get_server_info": - c.Hub.mu.RLock() - connectedClients := len(c.Hub.clients) - roomsCount := len(c.Hub.rooms) - uniqueIPs := len(c.Hub.clientsByIP) - c.Hub.mu.RUnlock() - - c.sendDirectResponse("server_info", map[string]interface{}{ - "connected_clients": connectedClients, - "rooms_count": roomsCount, - "unique_ips": uniqueIPs, - "your_info": map[string]interface{}{ - "client_id": c.ID, - "static_id": c.StaticID, - "ip_address": c.IPAddress, - "user_id": c.UserID, - "room": c.Room, - "connected_at": c.connectedAt.Unix(), - }, - }) - - case "get_clients_by_ip": - c.handleGetClientsByIP(msg) - - case "get_client_info": - c.handleGetClientInfo(msg) - - case "direct_message": - c.handleDirectMessage(msg) - - case "room_message": - c.handleRoomMessage(msg) - - case "broadcast": - c.Hub.broadcast <- msg - c.sendDirectResponse("broadcast_sent", "Message broadcasted to all clients") - - case "get_online_users": - c.sendOnlineUsers() - - case "database_query": - c.handleDatabaseQuery(msg) - - default: - c.sendDirectResponse("message_received", fmt.Sprintf("Message received: %v", msg.Data)) - c.Hub.broadcast <- msg - } -} - -// Handle get clients by IP -func (c *Client) handleGetClientsByIP(msg WebSocketMessage) { - data, ok := msg.Data.(map[string]interface{}) - if !ok { - c.sendErrorResponse("Invalid request format", "Expected object with ip_address") - return - } - - targetIP, exists := data["ip_address"].(string) - if !exists { - targetIP = c.IPAddress // Default to current client's IP - } - - c.Hub.mu.RLock() - ipClients := c.Hub.clientsByIP[targetIP] - var clientInfos []ClientInfo - - for _, client := range ipClients { - clientInfos = append(clientInfos, ClientInfo{ - ID: client.ID, - StaticID: client.StaticID, - IPAddress: client.IPAddress, - UserID: client.UserID, - Room: client.Room, - ConnectedAt: client.connectedAt, - LastPing: client.lastPing, - }) - } - c.Hub.mu.RUnlock() - - c.sendDirectResponse("clients_by_ip", map[string]interface{}{ - "ip_address": targetIP, - "clients": clientInfos, - "count": len(clientInfos), + "example": "ws://meninjar.dev.rssa.id:8070/api/v1/ws?client_id=test_client&room=test_room", + "timestamp": time.Now().Unix(), }) } -// Handle get specific client info -func (c *Client) handleGetClientInfo(msg WebSocketMessage) { - data, ok := msg.Data.(map[string]interface{}) - if !ok { - c.sendErrorResponse("Invalid request format", "Expected object with client_id or static_id") - return - } - - var targetClient *Client - - if clientID, exists := data["client_id"].(string); exists { - c.Hub.mu.RLock() - targetClient = c.Hub.clientsByID[clientID] - c.Hub.mu.RUnlock() - } else if staticID, exists := data["static_id"].(string); exists { - c.Hub.mu.RLock() - targetClient = c.Hub.clientsByStatic[staticID] - c.Hub.mu.RUnlock() - } - - if targetClient == nil { - c.sendErrorResponse("Client not found", "No client found with the specified ID") - return - } - - clientInfo := ClientInfo{ - ID: targetClient.ID, - StaticID: targetClient.StaticID, - IPAddress: targetClient.IPAddress, - UserID: targetClient.UserID, - Room: targetClient.Room, - ConnectedAt: targetClient.connectedAt, - LastPing: targetClient.lastPing, - } - - c.sendDirectResponse("client_info", clientInfo) -} - -// Enhanced online users with IP and static ID info -func (c *Client) sendOnlineUsers() { - c.Hub.mu.RLock() - var onlineUsers []map[string]interface{} - ipStats := make(map[string]int) - - for client := range c.Hub.clients { - onlineUsers = append(onlineUsers, map[string]interface{}{ - "client_id": client.ID, - "static_id": client.StaticID, - "user_id": client.UserID, - "room": client.Room, - "ip_address": client.IPAddress, - "connected_at": client.connectedAt.Unix(), - "last_ping": client.lastPing.Unix(), - }) - ipStats[client.IPAddress]++ - } - c.Hub.mu.RUnlock() - - c.sendDirectResponse("online_users", map[string]interface{}{ - "users": onlineUsers, - "total": len(onlineUsers), - "ip_stats": ipStats, - "unique_ips": len(ipStats), +// GetDetailedStats mengembalikan statistik detail WebSocket +func (h *WebSocketHandler) GetDetailedStats(c *gin.Context) { + detailedStats := h.monitoringManager.GetDetailedStats() + c.JSON(http.StatusOK, gin.H{ + "admin_stats": detailedStats, + "timestamp": time.Now().Unix(), }) } -// Enhanced database query handler -func (c *Client) handleDatabaseQuery(msg WebSocketMessage) { - data, ok := msg.Data.(map[string]interface{}) - if !ok { - c.sendErrorResponse("Invalid query format", "Expected object with query parameters") - return - } - - queryType, exists := data["query_type"].(string) - if !exists { - c.sendErrorResponse("Missing query_type", "query_type is required") - return - } - - switch queryType { - case "health_check": - health := c.Hub.dbService.Health() - c.sendDirectResponse("query_result", map[string]interface{}{ - "type": "health_check", - "result": health, - }) - - case "database_list": - dbList := c.Hub.dbService.ListDBs() - c.sendDirectResponse("query_result", map[string]interface{}{ - "type": "database_list", - "databases": dbList, - }) - - case "connection_stats": - c.Hub.mu.RLock() - stats := map[string]interface{}{ - "total_clients": len(c.Hub.clients), - "unique_ips": len(c.Hub.clientsByIP), - "static_clients": len(c.Hub.clientsByStatic), - "rooms": len(c.Hub.rooms), - } - c.Hub.mu.RUnlock() - - c.sendDirectResponse("query_result", map[string]interface{}{ - "type": "connection_stats", - "result": stats, - }) - - case "trigger_notification": - channel, channelExists := data["channel"].(string) - payload, payloadExists := data["payload"].(string) - - if !channelExists || !payloadExists { - c.sendErrorResponse("Missing Parameters", "channel and payload required") - return - } - - err := c.Hub.dbService.NotifyChange("default", channel, payload) - if err != nil { - c.sendErrorResponse("Notification Error", err.Error()) - return - } - - c.sendDirectResponse("query_result", map[string]interface{}{ - "type": "notification_sent", - "channel": channel, - "payload": payload, - }) - - default: - c.sendErrorResponse("Unsupported query", fmt.Sprintf("Query type '%s' not supported", queryType)) - } -} - -// Enhanced client search methods -func (h *WebSocketHandler) GetClientByID(clientID string) *Client { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - return h.hub.clientsByID[clientID] -} - -func (h *WebSocketHandler) GetClientByStaticID(staticID string) *Client { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - return h.hub.clientsByStatic[staticID] -} - -func (h *WebSocketHandler) GetClientsByIP(ipAddress string) []*Client { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - return h.hub.clientsByIP[ipAddress] -} - -func (h *WebSocketHandler) SendToClientByStaticID(staticID string, messageType string, data interface{}) bool { - client := h.GetClientByStaticID(staticID) - if client == nil { - return false - } - - msg := WebSocketMessage{ - Type: messageType, - Data: data, - Timestamp: time.Now(), - ClientID: client.ID, - MessageID: uuid.New().String(), - } - - select { - case h.hub.messageQueue <- msg: - return true - default: - return false - } -} - -func (h *WebSocketHandler) BroadcastToIP(ipAddress string, messageType string, data interface{}) int { - clients := h.GetClientsByIP(ipAddress) - count := 0 - - for _, client := range clients { - msg := WebSocketMessage{ - Type: messageType, - Data: data, - Timestamp: time.Now(), - ClientID: client.ID, - MessageID: uuid.New().String(), - } - - select { - case h.hub.messageQueue <- msg: - count++ - default: - // Skip if queue is full - } - } - - return count -} - -// 1. SERVER BROADCAST DATA TANPA PERMINTAAN CLIENT -func (h *WebSocketHandler) StartServerBroadcasters() { - // Heartbeat broadcaster - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - h.hub.mu.RLock() - connectedClients := len(h.hub.clients) - uniqueIPs := len(h.hub.clientsByIP) - staticClients := len(h.hub.clientsByStatic) - h.hub.mu.RUnlock() - - h.BroadcastMessage("server_heartbeat", map[string]interface{}{ - "message": "Server heartbeat", - "connected_clients": connectedClients, - "unique_ips": uniqueIPs, - "static_clients": staticClients, - "timestamp": time.Now().Unix(), - "server_id": "api-service-v1", - }) - case <-h.hub.ctx.Done(): - return - } - } - }() - - // System notification broadcaster - go func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - dbHealth := h.dbService.Health() - h.BroadcastMessage("system_status", map[string]interface{}{ - "type": "system_notification", - "database": dbHealth, - "timestamp": time.Now().Unix(), - "uptime": time.Since(time.Now()).String(), - }) - case <-h.hub.ctx.Done(): - return - } - } - }() - - // Data stream broadcaster (demo purpose) - go func() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - counter := 0 - - for { - select { - case <-ticker.C: - counter++ - h.BroadcastMessage("data_stream", map[string]interface{}{ - "id": counter, - "value": counter * 10, - "timestamp": time.Now().Unix(), - "type": "real_time_data", - }) - case <-h.hub.ctx.Done(): - return - } - } - }() -} - -// 4. SERVER MENGIRIM DATA JIKA ADA PERUBAHAN DATABASE -func (h *WebSocketHandler) StartDatabaseListener() { - // Cek apakah database utama adalah PostgreSQL - dbType, err := h.dbService.GetDBType(h.primaryDB) - if err != nil || dbType != database.Postgres { - h.logger.Error(fmt.Sprintf("Database notifications require PostgreSQL. Current DB type: %v", dbType)) - return - } - - channels := []string{"retribusi_changes", "peserta_changes", "system_changes"} - - err = h.dbService.ListenForChanges(h.hub.ctx, h.primaryDB, channels, func(channel, payload string) { - var changeData map[string]interface{} - if err := json.Unmarshal([]byte(payload), &changeData); err != nil { - h.logger.Error(fmt.Sprintf("Failed to parse database notification: %v", err)) - // Kirim raw payload jika JSON parsing gagal - changeData = map[string]interface{}{ - "raw_payload": payload, - "parse_error": err.Error(), - } - } - - h.BroadcastMessage("database_change", map[string]interface{}{ - "channel": channel, - "operation": changeData["operation"], - "table": changeData["table"], - "data": changeData["data"], - "timestamp": time.Now().Unix(), - "database": h.primaryDB, - }) - - h.logger.Info(fmt.Sprintf("Database change broadcasted: %s from %s", channel, h.primaryDB)) - }) - - if err != nil { - h.logger.Error(fmt.Sprintf("Failed to start database listener: %v", err)) - } -} - -func (h *WebSocketHandler) StartMessageQueue() { - // Start multiple workers for message processing - for i := 0; i < h.hub.queueWorkers; i++ { - go func(workerID int) { - for { - select { - case message := <-h.hub.messageQueue: - h.hub.broadcast <- message - case <-h.hub.ctx.Done(): - return - } - } - }(i) - } -} - -func (h *Hub) broadcastToClients(message WebSocketMessage) { - h.mu.RLock() - defer h.mu.RUnlock() - - if message.ClientID != "" { - // Send to specific client - for client := range h.clients { - if client.ID == message.ClientID { - select { - case client.Send <- message: - default: - h.unregisterClient(client) - } - break - } - } - return - } - - // Check if it's a room message - if data, ok := message.Data.(map[string]interface{}); ok { - if roomName, exists := data["room"].(string); exists { - if room, roomExists := h.rooms[roomName]; roomExists { - for client := range room { - select { - case client.Send <- message: - default: - h.unregisterClient(client) - } - } - } - return - } - } - - // Broadcast to all clients - for client := range h.clients { - select { - case client.Send <- message: - default: - h.unregisterClient(client) - } - } -} - -func (h *Hub) unregisterClient(client *Client) { - go func() { - h.unregister <- client - }() -} - -// 3. CLIENT MENGIRIM DATA KE CLIENT LAIN -func (c *Client) handleDirectMessage(msg WebSocketMessage) { - data, ok := msg.Data.(map[string]interface{}) - if !ok { - c.sendErrorResponse("Invalid direct message format", "Expected object with message data") - return - } - - targetClientID, exists := data["target_client_id"].(string) - if !exists { - c.sendErrorResponse("Missing target", "target_client_id is required") - return - } - - directMsg := WebSocketMessage{ - Type: "direct_message_received", - Data: map[string]interface{}{ - "from": c.ID, - "from_static_id": c.StaticID, - "from_ip": c.IPAddress, - "from_user_id": c.UserID, - "message": data["message"], - "original_msg_id": msg.MessageID, - }, - Timestamp: time.Now(), - ClientID: targetClientID, - MessageID: uuid.New().String(), - } - - c.Hub.broadcast <- directMsg - c.sendDirectResponse("direct_message_sent", map[string]interface{}{ - "target_client": targetClientID, - "message_id": directMsg.MessageID, +// DisconnectClient memutuskan koneksi klien tertentu +func (h *WebSocketHandler) DisconnectClient(c *gin.Context) { + clientID := c.Param("clientId") + success := h.monitoringManager.DisconnectClient(clientID) + c.JSON(http.StatusOK, gin.H{ + "status": "force disconnect attempted", + "client_id": clientID, + "success": success, + "timestamp": time.Now().Unix(), }) } -func (c *Client) handleRoomMessage(msg WebSocketMessage) { - data, ok := msg.Data.(map[string]interface{}) - if !ok { - c.sendErrorResponse("Invalid room message format", "Expected object with message data") - return +// CleanupInactiveClients membersihkan klien tidak aktif +func (h *WebSocketHandler) CleanupInactiveClients(c *gin.Context) { + var req struct { + InactiveMinutes int `json:"inactive_minutes"` + Force bool `json:"force"` + } + if err := c.ShouldBindJSON(&req); err != nil { + req.InactiveMinutes = 10 + req.Force = false } - roomName, exists := data["room"].(string) - if !exists { - roomName = c.Room - } - - roomMsg := WebSocketMessage{ - Type: "room_message_received", - Data: map[string]interface{}{ - "room": roomName, - "from": c.ID, - "from_static_id": c.StaticID, - "from_ip": c.IPAddress, - "from_user_id": c.UserID, - "message": data["message"], - "original_msg_id": msg.MessageID, - }, - Timestamp: time.Now(), - MessageID: uuid.New().String(), - } - - // Send to room members - c.Hub.mu.RLock() - if room, exists := c.Hub.rooms[roomName]; exists { - for client := range room { - if client.ID != c.ID { - select { - case client.Send <- roomMsg: - default: - logger.Error(fmt.Sprintf("Failed to send room message to client %s", client.ID)) - } - } - } - } - c.Hub.mu.RUnlock() - - c.sendDirectResponse("room_message_sent", map[string]interface{}{ - "room": roomName, - "message_id": roomMsg.MessageID, + cleanedCount := h.monitoringManager.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute) + c.JSON(http.StatusOK, gin.H{ + "status": "admin cleanup completed", + "cleaned_clients": cleanedCount, + "inactive_minutes": req.InactiveMinutes, + "force": req.Force, + "timestamp": time.Now().Unix(), }) } - -func (c *Client) readPump() { - defer func() { - c.Hub.unregister <- c - c.Conn.Close() - logger.Info(fmt.Sprintf("Client %s readPump terminated", c.ID)) - }() - - // Konfigurasi connection limits - c.Conn.SetReadLimit(MaxMessageSize) - c.resetReadDeadline() // Set initial deadline - - // Ping/Pong handlers dengan logging yang lebih baik - c.Conn.SetPingHandler(func(message string) error { - logger.Debug(fmt.Sprintf("Client %s received ping", c.ID)) - c.resetReadDeadline() - return c.Conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(WriteTimeout)) - }) - - c.Conn.SetPongHandler(func(message string) error { - c.mu.Lock() - c.lastPong = time.Now() - c.isActive = true - c.mu.Unlock() - c.resetReadDeadline() - logger.Debug(fmt.Sprintf("Client %s received pong", c.ID)) - return nil - }) - - for { - select { - case <-c.ctx.Done(): - logger.Info(fmt.Sprintf("Client %s context cancelled", c.ID)) - return - default: - _, message, err := c.Conn.ReadMessage() - if err != nil { - if websocket.IsUnexpectedCloseError(err, - websocket.CloseGoingAway, - websocket.CloseAbnormalClosure, - websocket.CloseNormalClosure) { - logger.Error(fmt.Sprintf("WebSocket unexpected close for client %s: %v", c.ID, err)) - } else { - logger.Info(fmt.Sprintf("WebSocket closed for client %s: %v", c.ID, err)) - } - return - } - - // PENTING: Reset deadline setiap kali ada pesan masuk - c.resetReadDeadline() - c.updateLastActivity() - - var msg WebSocketMessage - if err := json.Unmarshal(message, &msg); err != nil { - c.sendErrorResponse("Invalid message format", err.Error()) - continue - } - - msg.Timestamp = time.Now() - msg.ClientID = c.ID - if msg.MessageID == "" { - msg.MessageID = uuid.New().String() - } - - c.handleMessage(msg) - } - } -} - -// resetReadDeadline - Reset read deadline dengan timeout yang lebih panjang -func (c *Client) resetReadDeadline() { - c.Conn.SetReadDeadline(time.Now().Add(ReadTimeout)) -} - -// updateLastActivity - Update waktu aktivitas terakhir -func (c *Client) updateLastActivity() { - c.mu.Lock() - defer c.mu.Unlock() - c.lastPing = time.Now() - c.isActive = true -} - -// sendPing - Kirim ping message dengan proper error handling -func (c *Client) sendPing() error { - c.Conn.SetWriteDeadline(time.Now().Add(WriteTimeout)) - if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return err - } - - c.mu.Lock() - c.lastPing = time.Now() - c.mu.Unlock() - - logger.Debug(fmt.Sprintf("Ping sent to client %s", c.ID)) - return nil -} - -// isPongTimeout - Cek apakah client sudah timeout dalam merespons pong -func (c *Client) isPongTimeout() bool { - c.mu.RLock() - defer c.mu.RUnlock() - - // Jika belum pernah menerima pong, gunakan lastPing sebagai baseline - lastActivity := c.lastPong - if lastActivity.IsZero() { - lastActivity = c.lastPing - } - - return time.Since(lastActivity) > PongTimeout -} - -// isClientActive - Cek apakah client masih aktif -func (c *Client) isClientActive() bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.isActive && time.Since(c.lastPing) < PongTimeout -} - -// gracefulClose - Tutup koneksi dengan graceful -func (c *Client) gracefulClose() { - c.mu.Lock() - c.isActive = false - c.mu.Unlock() - - // Kirim close message - c.Conn.SetWriteDeadline(time.Now().Add(WriteTimeout)) - c.Conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) - - // Cancel context - c.cancel() - - logger.Info(fmt.Sprintf("Client %s closed gracefully", c.ID)) -} -func (c *Client) sendDirectResponse(messageType string, data interface{}) { - response := WebSocketMessage{ - Type: messageType, - Data: data, - Timestamp: time.Now(), - MessageID: uuid.New().String(), - } - - select { - case c.Send <- response: - default: - logger.Error("Failed to send direct response to client") - } -} - -func (c *Client) sendErrorResponse(error, details string) { - c.sendDirectResponse("error", map[string]interface{}{ - "error": error, - "details": details, - }) -} - -func (c *Client) writePump() { - ticker := time.NewTicker(PingInterval) - defer func() { - ticker.Stop() - c.Conn.Close() - logger.Info(fmt.Sprintf("Client %s writePump terminated", c.ID)) - }() - - for { - select { - case message, ok := <-c.Send: - c.Conn.SetWriteDeadline(time.Now().Add(WriteTimeout)) - if !ok { - c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - - if err := c.Conn.WriteJSON(message); err != nil { - logger.Error(fmt.Sprintf("Failed to write message to client %s: %v", c.ID, err)) - return - } - - case <-ticker.C: - // Kirim ping dan cek apakah client masih responsif - if err := c.sendPing(); err != nil { - logger.Error(fmt.Sprintf("Failed to send ping to client %s: %v", c.ID, err)) - return - } - - // Cek apakah client sudah terlalu lama tidak merespons pong - if c.isPongTimeout() { - logger.Warn(fmt.Sprintf("Client %s pong timeout, disconnecting", c.ID)) - return - } - - case <-c.ctx.Done(): - logger.Info(fmt.Sprintf("Client %s writePump context cancelled", c.ID)) - return - } - } -} - -// Broadcast methods -func (h *WebSocketHandler) BroadcastMessage(messageType string, data interface{}) { - msg := WebSocketMessage{ - Type: messageType, - Data: data, - Timestamp: time.Now(), - MessageID: uuid.New().String(), - } - - select { - case h.hub.messageQueue <- msg: - default: - logger.Error("Message queue full, dropping message") - } -} - -func (h *WebSocketHandler) BroadcastToRoom(room string, messageType string, data interface{}) { - msg := WebSocketMessage{ - Type: messageType, - Data: map[string]interface{}{ - "room": room, - "data": data, - }, - Timestamp: time.Now(), - MessageID: uuid.New().String(), - } - - select { - case h.hub.messageQueue <- msg: - default: - logger.Error("Message queue full, dropping room message") - } -} - -func (h *WebSocketHandler) SendToClient(clientID string, messageType string, data interface{}) { - msg := WebSocketMessage{ - Type: messageType, - Data: data, - Timestamp: time.Now(), - ClientID: clientID, - MessageID: uuid.New().String(), - } - - select { - case h.hub.messageQueue <- msg: - default: - logger.Error("Message queue full, dropping client message") - } -} - -func (h *WebSocketHandler) GetConnectedClients() int { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - return len(h.hub.clients) -} - -func (h *WebSocketHandler) GetRoomClients(room string) int { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - if roomClients, exists := h.hub.rooms[room]; exists { - return len(roomClients) - } - return 0 -} - -func (h *WebSocketHandler) Shutdown() { - h.hub.cancel() - - h.hub.mu.RLock() - for client := range h.hub.clients { - client.cancel() - } - h.hub.mu.RUnlock() -} - -// 1. GetDetailedStats - Mengembalikan statistik detail -func (h *WebSocketHandler) GetDetailedStats() DetailedStats { - h.hub.mu.RLock() - - // Calculate IP distribution - ipDistribution := make(map[string]int) - for ip, clients := range h.hub.clientsByIP { - ipDistribution[ip] = len(clients) - } - - // Calculate room distribution - roomDistribution := make(map[string]int) - for room, clients := range h.hub.rooms { - roomDistribution[room] = len(clients) - } - - stats := DetailedStats{ - ConnectedClients: len(h.hub.clients), - UniqueIPs: len(h.hub.clientsByIP), - StaticClients: len(h.hub.clientsByStatic), - ActiveRooms: len(h.hub.rooms), - IPDistribution: ipDistribution, - RoomDistribution: roomDistribution, - MessageQueueSize: len(h.hub.messageQueue), - QueueWorkers: h.hub.queueWorkers, - Uptime: time.Since(h.hub.startTime), - Timestamp: time.Now().Unix(), - } - - h.hub.mu.RUnlock() - - return stats -} - -// 2. GetAllClients - Mengembalikan semua client yang terhubung -func (h *WebSocketHandler) GetAllClients() []ClientInfo { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - - var clients []ClientInfo - - for client := range h.hub.clients { - clientInfo := ClientInfo{ - ID: client.ID, - StaticID: client.StaticID, - IPAddress: client.IPAddress, - UserID: client.UserID, - Room: client.Room, - ConnectedAt: client.connectedAt, // Perbaikan: gunakan connectedAt bukan ConnectedAt - LastPing: client.lastPing, // Perbaikan: gunakan lastPing bukan LastPing - } - clients = append(clients, clientInfo) - } - - return clients -} - -// 3. GetAllRooms - Mengembalikan semua room dan anggotanya -func (h *WebSocketHandler) GetAllRooms() map[string][]ClientInfo { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - - rooms := make(map[string][]ClientInfo) - - for roomName, clients := range h.hub.rooms { - var roomClients []ClientInfo - - for client := range clients { - clientInfo := ClientInfo{ - ID: client.ID, - StaticID: client.StaticID, - IPAddress: client.IPAddress, - UserID: client.UserID, - Room: client.Room, - ConnectedAt: client.connectedAt, - LastPing: client.lastPing, - } - roomClients = append(roomClients, clientInfo) - } - - rooms[roomName] = roomClients - } - - return rooms -} - -// 4. GetMonitoringData - Mengembalikan data monitoring lengkap -func (h *WebSocketHandler) GetMonitoringData() MonitoringData { - stats := h.GetDetailedStats() - - h.hub.activityMu.RLock() - recentActivity := make([]ActivityLog, 0) - // Get last 100 activities - start := len(h.hub.activityLog) - 100 - if start < 0 { - start = 0 - } - for i := start; i < len(h.hub.activityLog); i++ { - recentActivity = append(recentActivity, h.hub.activityLog[i]) - } - h.hub.activityMu.RUnlock() - - // Get system health from database service - systemHealth := make(map[string]interface{}) - if h.dbService != nil { - systemHealth["databases"] = h.dbService.Health() - systemHealth["available_dbs"] = h.dbService.ListDBs() - } - systemHealth["websocket_status"] = "healthy" - systemHealth["uptime_seconds"] = time.Since(h.hub.startTime).Seconds() - - // Calculate performance metrics - uptime := time.Since(h.hub.startTime) - var messagesPerSecond float64 - var errorRate float64 - - if uptime.Seconds() > 0 { - messagesPerSecond = float64(h.hub.messageCount) / uptime.Seconds() - } - - if h.hub.messageCount > 0 { - errorRate = (float64(h.hub.errorCount) / float64(h.hub.messageCount)) * 100 - } - - performance := PerformanceMetrics{ - MessagesPerSecond: messagesPerSecond, - AverageLatency: 2.5, // Mock value - implement actual latency tracking - ErrorRate: errorRate, - MemoryUsage: 0, // Mock value - implement actual memory tracking - } - - return MonitoringData{ - Stats: stats, - RecentActivity: recentActivity, - SystemHealth: systemHealth, - Performance: performance, - } -} -func (h *WebSocketHandler) GetRoomClientCount(room string) int { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - - if roomClients, exists := h.hub.rooms[room]; exists { - return len(roomClients) - } - return 0 -} -func (h *WebSocketHandler) GetActiveClients(olderThan time.Duration) []ClientInfo { - h.hub.mu.RLock() - defer h.hub.mu.RUnlock() - - var activeClients []ClientInfo - cutoff := time.Now().Add(-olderThan) - - for client := range h.hub.clients { - if client.lastPing.After(cutoff) { - activeClients = append(activeClients, ClientInfo{ - ID: client.ID, - StaticID: client.StaticID, - IPAddress: client.IPAddress, - UserID: client.UserID, - Room: client.Room, - ConnectedAt: client.connectedAt, - LastPing: client.lastPing, - }) - } - } - - return activeClients -} -func (h *WebSocketHandler) CleanupInactiveClients(inactiveTimeout time.Duration) int { - h.hub.mu.RLock() - var inactiveClients []*Client - cutoff := time.Now().Add(-inactiveTimeout) - - for client := range h.hub.clients { - if client.lastPing.Before(cutoff) { - inactiveClients = append(inactiveClients, client) - } - } - h.hub.mu.RUnlock() - - // Disconnect inactive clients - for _, client := range inactiveClients { - h.hub.logActivity("cleanup_disconnect", client.ID, - fmt.Sprintf("Inactive for %v", time.Since(client.lastPing))) - client.cancel() - client.Conn.Close() - } - - return len(inactiveClients) -} - -// 5. DisconnectClient - Memutus koneksi client tertentu -func (h *WebSocketHandler) DisconnectClient(clientID string) bool { - h.hub.mu.RLock() - client, exists := h.hub.clientsByID[clientID] - h.hub.mu.RUnlock() - - if !exists { - return false - } - - // Log activity - h.hub.logActivity("force_disconnect", clientID, "Client disconnected by admin") - - // Cancel context and close connection - client.cancel() - client.Conn.Close() - - // The client will be automatically removed from hub in the Run() loop - return true -} - -func (h *WebSocketHandler) StartConnectionMonitor() { - ticker := time.NewTicker(2 * time.Minute) // Check setiap 2 menit - defer ticker.Stop() - - for { - select { - case <-ticker.C: - h.cleanupInactiveConnections() - h.logConnectionStats() - - case <-h.hub.ctx.Done(): - return - } - } -} - -// cleanupInactiveConnections - Bersihkan koneksi yang tidak aktif -func (h *WebSocketHandler) cleanupInactiveConnections() { - h.hub.mu.RLock() - var inactiveClients []*Client - - for client := range h.hub.clients { - if !client.isClientActive() { - inactiveClients = append(inactiveClients, client) - } - } - h.hub.mu.RUnlock() - - // Disconnect inactive clients - for _, client := range inactiveClients { - h.hub.logActivity("cleanup_disconnect", client.ID, - fmt.Sprintf("Inactive for %v", time.Since(client.lastPing))) - client.gracefulClose() - } - - if len(inactiveClients) > 0 { - logger.Info(fmt.Sprintf("Cleaned up %d inactive connections", len(inactiveClients))) - } -} - -// logConnectionStats - Log statistik koneksi -func (h *WebSocketHandler) logConnectionStats() { - stats := h.GetDetailedStats() - logger.Info(fmt.Sprintf("WebSocket Stats - Clients: %d, IPs: %d, Rooms: %d, Queue: %d", - stats.ConnectedClients, stats.UniqueIPs, stats.ActiveRooms, stats.MessageQueueSize)) -} - -func (h *Hub) logActivity(event, clientID, details string) { - h.activityMu.Lock() - defer h.activityMu.Unlock() - - activity := ActivityLog{ - Timestamp: time.Now(), - Event: event, - ClientID: clientID, - Details: details, - } - - h.activityLog = append(h.activityLog, activity) - - // Keep only last 1000 activities - if len(h.activityLog) > 1000 { - h.activityLog = h.activityLog[1:] - } -} - -// Example Database Use Triger -// -- Trigger function untuk notifikasi perubahan data -// CREATE OR REPLACE FUNCTION notify_data_change() RETURNS trigger AS $$ -// DECLARE -// channel text := 'retribusi_changes'; -// payload json; -// BEGIN -// -- Tentukan channel berdasarkan table -// IF TG_TABLE_NAME = 'retribusi' THEN -// channel := 'retribusi_changes'; -// ELSIF TG_TABLE_NAME = 'peserta' THEN -// channel := 'peserta_changes'; -// END IF; - -// -- Buat payload -// IF TG_OP = 'DELETE' THEN -// payload = json_build_object( -// 'operation', TG_OP, -// 'table', TG_TABLE_NAME, -// 'data', row_to_json(OLD) -// ); -// ELSE -// payload = json_build_object( -// 'operation', TG_OP, -// 'table', TG_TABLE_NAME, -// 'data', row_to_json(NEW) -// ); -// END IF; - -// -- Kirim notifikasi -// PERFORM pg_notify(channel, payload::text); - -// RETURN COALESCE(NEW, OLD); -// END; -// $$ LANGUAGE plpgsql; - -// -- Trigger untuk table retribusi -// CREATE TRIGGER retribusi_notify_trigger -// AFTER INSERT OR UPDATE OR DELETE ON retribusi -// FOR EACH ROW EXECUTE FUNCTION notify_data_change(); - -// -- Trigger untuk table peserta -// CREATE TRIGGER peserta_notify_trigger -// AFTER INSERT OR UPDATE OR DELETE ON peserta -// FOR EACH ROW EXECUTE FUNCTION notify_data_change(); diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index 77d22c6..017af8e 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -6,13 +6,11 @@ import ( authHandlers "api-service/internal/handlers/auth" healthcheckHandlers "api-service/internal/handlers/healthcheck" retribusiHandlers "api-service/internal/handlers/retribusi" - "api-service/internal/handlers/websocket" websocketHandlers "api-service/internal/handlers/websocket" "api-service/internal/middleware" services "api-service/internal/services/auth" + websocketServices "api-service/internal/services/websocket" "api-service/pkg/logger" - "encoding/json" - "strconv" "time" "github.com/gin-gonic/gin" @@ -41,9 +39,6 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // Initialize database service dbService := database.New(cfg) - // Initialize WebSocket handler with enhanced features - websocketHandler := websocketHandlers.NewWebSocketHandler(cfg, dbService) - // ============================================================================= // HEALTH CHECK & SYSTEM ROUTES // ============================================================================= @@ -63,7 +58,7 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { c.JSON(200, gin.H{ "service": "API Service v1.0.0", "websocket_active": true, - "connected_clients": websocketHandler.GetConnectedClients(), + "connected_clients": 0, // TODO: implement websocket handler "databases": dbService.ListDBs(), "timestamp": time.Now().Unix(), }) @@ -84,11 +79,6 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // WEBSOCKET TEST CLIENT // ============================================================================= - // router.GET("/websocket-test", func(c *gin.Context) { - // c.Header("Content-Type", "text/html") - // c.String(http.StatusOK, getWebSocketTestHTML()) - // }) - // ============================================================================= // API v1 GROUP // ============================================================================= @@ -116,559 +106,24 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // WEBSOCKET ROUTES // ============================================================================= - // Main WebSocket endpoint with enhanced features - v1.GET("/ws", websocketHandler.HandleWebSocket) - - // WebSocket management API - wsAPI := router.Group("/api/websocket") - { - // ============================================================================= - // BASIC BROADCASTING - // ============================================================================= - - wsAPI.POST("/broadcast", func(c *gin.Context) { - var req struct { - Type string `json:"type"` - Message interface{} `json:"message"` - Database string `json:"database,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - websocketHandler.BroadcastMessage(req.Type, req.Message) - c.JSON(200, gin.H{ - "status": "broadcast sent", - "clients_count": websocketHandler.GetConnectedClients(), - "timestamp": time.Now().Unix(), - }) - }) - - wsAPI.POST("/broadcast/room/:room", func(c *gin.Context) { - room := c.Param("room") - var req struct { - Type string `json:"type"` - Message interface{} `json:"message"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - websocketHandler.BroadcastToRoom(room, req.Type, req.Message) - c.JSON(200, gin.H{ - "status": "room broadcast sent", - "room": room, - "clients_count": websocketHandler.GetRoomClientCount(room), // Fix: gunakan GetRoomClientCount - "timestamp": time.Now().Unix(), - }) - }) - - // ============================================================================= - // ENHANCED CLIENT TARGETING - // ============================================================================= - - wsAPI.POST("/send/:clientId", func(c *gin.Context) { - clientID := c.Param("clientId") - var req struct { - Type string `json:"type"` - Message interface{} `json:"message"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - websocketHandler.SendToClient(clientID, req.Type, req.Message) - c.JSON(200, gin.H{ - "status": "message sent", - "client_id": clientID, - "timestamp": time.Now().Unix(), - }) - }) - - // Send to client by static ID - wsAPI.POST("/send/static/:staticId", func(c *gin.Context) { - staticID := c.Param("staticId") - logger.Infof("Sending message to static client: %s", staticID) - var req struct { - Type string `json:"type"` - Message interface{} `json:"message"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - success := websocketHandler.SendToClientByStaticID(staticID, req.Type, req.Message) - if success { - c.JSON(200, gin.H{ - "status": "message sent to static client", - "static_id": staticID, - "timestamp": time.Now().Unix(), - }) - } else { - c.JSON(404, gin.H{ - "error": "static client not found", - "static_id": staticID, - "timestamp": time.Now().Unix(), - }) - } - }) - - // Broadcast to all clients from specific IP - wsAPI.POST("/broadcast/ip/:ipAddress", func(c *gin.Context) { - ipAddress := c.Param("ipAddress") - var req struct { - Type string `json:"type"` - Message interface{} `json:"message"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - count := websocketHandler.BroadcastToIP(ipAddress, req.Type, req.Message) - c.JSON(200, gin.H{ - "status": "ip broadcast sent", - "ip_address": ipAddress, - "clients_count": count, - "timestamp": time.Now().Unix(), - }) - }) - - // ============================================================================= - // CLIENT INFORMATION & STATISTICS - // ============================================================================= - - wsAPI.GET("/stats", func(c *gin.Context) { - c.JSON(200, gin.H{ - "connected_clients": websocketHandler.GetConnectedClients(), - "databases": dbService.ListDBs(), - "database_health": dbService.Health(), - "timestamp": time.Now().Unix(), - }) - }) - - wsAPI.GET("/stats/detailed", func(c *gin.Context) { - stats := websocketHandler.GetDetailedStats() - c.JSON(200, gin.H{ - "stats": stats, - "timestamp": time.Now().Unix(), - }) - }) - - wsAPI.GET("/clients", func(c *gin.Context) { - clients := websocketHandler.GetAllClients() - c.JSON(200, gin.H{ - "clients": clients, - "count": len(clients), - "timestamp": time.Now().Unix(), - }) - }) - - // Fix: Perbaiki GetClientsByIP untuk menggunakan ClientInfo - wsAPI.GET("/clients/by-ip/:ipAddress", func(c *gin.Context) { - ipAddress := c.Param("ipAddress") - client := websocketHandler.GetClientsByIP(ipAddress) - if client == nil { - c.JSON(404, gin.H{ - "error": "client not found", - "ip_address": ipAddress, - "timestamp": time.Now().Unix(), - }) - return - } - - // Use ClientInfo struct instead of direct field access - clientInfo := websocketHandler.GetAllClients() - var targetClientInfo *websocket.ClientInfo - for i := range clientInfo { - if clientInfo[i].ID == ipAddress { - targetClientInfo = &clientInfo[i] - break - } - } - - if targetClientInfo == nil { - c.JSON(404, gin.H{ - "error": "ipAddress not found", - "client_id": ipAddress, - "timestamp": time.Now().Unix(), - }) - return - } - - c.JSON(200, gin.H{ - "client": map[string]interface{}{ - "id": targetClientInfo.ID, - "static_id": targetClientInfo.StaticID, - "ip_address": targetClientInfo.IPAddress, - "user_id": targetClientInfo.UserID, - "room": targetClientInfo.Room, - "connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field - "last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field - }, - "timestamp": time.Now().Unix(), - }) - - }) - - // Fix: Perbaiki GetClientByID response - wsAPI.GET("/client/:clientId", func(c *gin.Context) { - clientID := c.Param("clientId") - client := websocketHandler.GetClientByID(clientID) - - if client == nil { - c.JSON(404, gin.H{ - "error": "client not found", - "client_id": clientID, - "timestamp": time.Now().Unix(), - }) - return - } - - // Use ClientInfo struct instead of direct field access - clientInfo := websocketHandler.GetAllClients() - var targetClientInfo *websocket.ClientInfo - for i := range clientInfo { - if clientInfo[i].ID == clientID { - targetClientInfo = &clientInfo[i] - break - } - } - - if targetClientInfo == nil { - c.JSON(404, gin.H{ - "error": "client not found", - "client_id": clientID, - "timestamp": time.Now().Unix(), - }) - return - } - - c.JSON(200, gin.H{ - "client": map[string]interface{}{ - "id": targetClientInfo.ID, - "static_id": targetClientInfo.StaticID, - "ip_address": targetClientInfo.IPAddress, - "user_id": targetClientInfo.UserID, - "room": targetClientInfo.Room, - "connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field - "last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field - }, - "timestamp": time.Now().Unix(), - }) - }) - - // Fix: Perbaiki GetClientByStaticID response - wsAPI.GET("/client/static/:staticId", func(c *gin.Context) { - staticID := c.Param("staticId") - client := websocketHandler.GetClientByStaticID(staticID) - - if client == nil { - c.JSON(404, gin.H{ - "error": "static client not found", - "static_id": staticID, - "timestamp": time.Now().Unix(), - }) - return - } - - // Use ClientInfo struct instead of direct field access - clientInfo := websocketHandler.GetAllClients() - var targetClientInfo *websocket.ClientInfo - for i := range clientInfo { - if clientInfo[i].StaticID == staticID { - targetClientInfo = &clientInfo[i] - break - } - } - - if targetClientInfo == nil { - c.JSON(404, gin.H{ - "error": "static client not found", - "static_id": staticID, - "timestamp": time.Now().Unix(), - }) - return - } - - c.JSON(200, gin.H{ - "client": map[string]interface{}{ - "id": targetClientInfo.ID, - "static_id": targetClientInfo.StaticID, - "ip_address": targetClientInfo.IPAddress, - "user_id": targetClientInfo.UserID, - "room": targetClientInfo.Room, - "connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field - "last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field - }, - "timestamp": time.Now().Unix(), - }) - }) - - // ============================================================================= - // ACTIVE CLIENTS & CLEANUP - // ============================================================================= - - // Tambahkan endpoint untuk active clients - wsAPI.GET("/clients/active", func(c *gin.Context) { - // Default: clients active dalam 5 menit terakhir - minutes := c.DefaultQuery("minutes", "5") - minutesInt, err := strconv.Atoi(minutes) - if err != nil { - minutesInt = 5 - } - - activeClients := websocketHandler.GetActiveClients(time.Duration(minutesInt) * time.Minute) - c.JSON(200, gin.H{ - "active_clients": activeClients, - "count": len(activeClients), - "threshold_minutes": minutesInt, - "timestamp": time.Now().Unix(), - }) - }) - - // Tambahkan endpoint untuk cleanup inactive clients - wsAPI.POST("/cleanup/inactive", func(c *gin.Context) { - var req struct { - InactiveMinutes int `json:"inactive_minutes"` - } - if err := c.ShouldBindJSON(&req); err != nil { - req.InactiveMinutes = 30 // Default 30 minutes - } - - if req.InactiveMinutes <= 0 { - req.InactiveMinutes = 30 - } - - cleanedCount := websocketHandler.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute) - c.JSON(200, gin.H{ - "status": "cleanup completed", - "cleaned_clients": cleanedCount, - "inactive_minutes": req.InactiveMinutes, - "timestamp": time.Now().Unix(), - }) - }) - - // ============================================================================= - // DATABASE NOTIFICATIONS - // ============================================================================= - - wsAPI.POST("/notify/:database/:channel", func(c *gin.Context) { - database := c.Param("database") - channel := c.Param("channel") - - var req struct { - Payload interface{} `json:"payload"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - payloadJSON, _ := json.Marshal(req.Payload) - err := dbService.NotifyChange(database, channel, string(payloadJSON)) - if err != nil { - c.JSON(500, gin.H{ - "error": err.Error(), - "database": database, - "channel": channel, - "timestamp": time.Now().Unix(), - }) - return - } - - c.JSON(200, gin.H{ - "status": "notification sent", - "database": database, - "channel": channel, - "timestamp": time.Now().Unix(), - }) - }) - - // Test database notification - wsAPI.POST("/test-notification", func(c *gin.Context) { - var req struct { - Database string `json:"database"` - Channel string `json:"channel"` - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - // Default values - if req.Database == "" { - req.Database = "default" - } - if req.Channel == "" { - req.Channel = "system_changes" - } - if req.Message == "" { - req.Message = "Test notification from API" - } - - payload := map[string]interface{}{ - "operation": "API_TEST", - "table": "manual_test", - "data": map[string]interface{}{ - "message": req.Message, - "test_data": req.Data, - "timestamp": time.Now().Unix(), - }, - } - - payloadJSON, _ := json.Marshal(payload) - err := dbService.NotifyChange(req.Database, req.Channel, string(payloadJSON)) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{ - "status": "test notification sent", - "database": req.Database, - "channel": req.Channel, - "payload": payload, - "timestamp": time.Now().Unix(), - }) - }) - - // ============================================================================= - // ROOM MANAGEMENT - // ============================================================================= - - wsAPI.GET("/rooms", func(c *gin.Context) { - rooms := websocketHandler.GetAllRooms() - c.JSON(200, gin.H{ - "rooms": rooms, - "count": len(rooms), - "timestamp": time.Now().Unix(), - }) - }) - - wsAPI.GET("/room/:room/clients", func(c *gin.Context) { - room := c.Param("room") - clientCount := websocketHandler.GetRoomClientCount(room) - - // Get detailed room info - allRooms := websocketHandler.GetAllRooms() - roomClients := allRooms[room] - - c.JSON(200, gin.H{ - "room": room, - "client_count": clientCount, - "clients": roomClients, - "timestamp": time.Now().Unix(), - }) - }) - - // ============================================================================= - // MONITORING & DEBUGGING - // ============================================================================= - - wsAPI.GET("/monitor", func(c *gin.Context) { - monitor := websocketHandler.GetMonitoringData() - c.JSON(200, monitor) - }) - - wsAPI.POST("/ping-client/:clientId", func(c *gin.Context) { - clientID := c.Param("clientId") - websocketHandler.SendToClient(clientID, "server_ping", map[string]interface{}{ - "message": "Ping from server", - "timestamp": time.Now().Unix(), - }) - c.JSON(200, gin.H{ - "status": "ping sent", - "client_id": clientID, - "timestamp": time.Now().Unix(), - }) - }) - - // Disconnect specific client - wsAPI.POST("/disconnect/:clientId", func(c *gin.Context) { - clientID := c.Param("clientId") - success := websocketHandler.DisconnectClient(clientID) - if success { - c.JSON(200, gin.H{ - "status": "client disconnected", - "client_id": clientID, - "timestamp": time.Now().Unix(), - }) - } else { - c.JSON(404, gin.H{ - "error": "client not found", - "client_id": clientID, - "timestamp": time.Now().Unix(), - }) - } - }) - - // ============================================================================= - // BULK OPERATIONS - // ============================================================================= - - // Broadcast to multiple clients - wsAPI.POST("/broadcast/bulk", func(c *gin.Context) { - var req struct { - ClientIDs []string `json:"client_ids"` - Type string `json:"type"` - Message interface{} `json:"message"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - successCount := 0 - for _, clientID := range req.ClientIDs { - websocketHandler.SendToClient(clientID, req.Type, req.Message) - successCount++ - } - - c.JSON(200, gin.H{ - "status": "bulk broadcast sent", - "total_clients": len(req.ClientIDs), - "success_count": successCount, - "timestamp": time.Now().Unix(), - }) - }) - - // Disconnect multiple clients - wsAPI.POST("/disconnect/bulk", func(c *gin.Context) { - var req struct { - ClientIDs []string `json:"client_ids"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - successCount := 0 - for _, clientID := range req.ClientIDs { - if websocketHandler.DisconnectClient(clientID) { - successCount++ - } - } - - c.JSON(200, gin.H{ - "status": "bulk disconnect completed", - "total_clients": len(req.ClientIDs), - "success_count": successCount, - "timestamp": time.Now().Unix(), - }) - }) - } + // WebSocket handler will be initialized after websocketHub is defined // ============================================================================= // PUBLISHED ROUTES // ============================================================================= + // Initialize WebSocket hub with database service + websocketHub := websocketServices.NewHub(cfg.WebSocket) + go websocketHub.Run() // Start WebSocket hub in background + + // Initialize WebSocket handler + websocketHandler := websocketHandlers.NewWebSocketHandler(websocketHub, cfg.WebSocket) + + // WebSocket routes + v1.GET("/ws", websocketHandler.HandleWebSocket) + v1.GET("/ws/test", websocketHandler.TestWebSocketConnection) + v1.GET("/ws/stats", websocketHandler.GetWebSocketStats) + // Retribusi endpoints with WebSocket notifications retribusiHandler := retribusiHandlers.NewRetribusiHandler() retribusiGroup := v1.Group("/retribusi") @@ -684,91 +139,36 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // Trigger WebSocket notification after successful creation if c.Writer.Status() == 200 || c.Writer.Status() == 201 { - websocketHandler.BroadcastMessage("retribusi_created", map[string]interface{}{ - "message": "New retribusi record created", - "timestamp": time.Now().Unix(), - }) + // Notify database change via WebSocket + // websocketHub.NotifyDatabaseChange("postgres_satudata", "retribusi_changes", + // fmt.Sprintf(`{"action": "created", "timestamp": "%s"}`, time.Now().Format(time.RFC3339))) } }) retribusiGroup.PUT("/id/:id", func(c *gin.Context) { - id := c.Param("id") + // id := c.Param("id") retribusiHandler.UpdateRetribusi(c) // Trigger WebSocket notification after successful update if c.Writer.Status() == 200 { - websocketHandler.BroadcastMessage("retribusi_updated", map[string]interface{}{ - "message": "Retribusi record updated", - "id": id, - "timestamp": time.Now().Unix(), - }) + // Notify database change via WebSocket + // websocketHub.NotifyDatabaseChange("postgres_satudata", "retribusi_changes", + // fmt.Sprintf(`{"action": "updated", "id": "%s", "timestamp": "%s"}`, id, time.Now().Format(time.RFC3339))) } }) retribusiGroup.DELETE("/id/:id", func(c *gin.Context) { - id := c.Param("id") + // id := c.Param("id") retribusiHandler.DeleteRetribusi(c) // Trigger WebSocket notification after successful deletion if c.Writer.Status() == 200 { - websocketHandler.BroadcastMessage("retribusi_deleted", map[string]interface{}{ - "message": "Retribusi record deleted", - "id": id, - "timestamp": time.Now().Unix(), - }) + // Notify database change via WebSocket + // websocketHub.NotifyDatabaseChange("postgres_satudata", "retribusi_changes", + // fmt.Sprintf(`{"action": "deleted", "id": "%s", "timestamp": "%s"}`, id, time.Now().Format(time.RFC3339))) } }) } - // ============================================================================= - // PROTECTED ROUTES (Authentication Required) - // ============================================================================= - - protected := v1.Group("/") - protected.Use(middleware.ConfigurableAuthMiddleware(cfg)) - - // Protected WebSocket management (optional) - protectedWS := protected.Group("/ws-admin") - { - protectedWS.GET("/stats", func(c *gin.Context) { - detailedStats := websocketHandler.GetDetailedStats() - c.JSON(200, gin.H{ - "admin_stats": detailedStats, - "timestamp": time.Now().Unix(), - }) - }) - - protectedWS.POST("/force-disconnect/:clientId", func(c *gin.Context) { - clientID := c.Param("clientId") - success := websocketHandler.DisconnectClient(clientID) - c.JSON(200, gin.H{ - "status": "force disconnect attempted", - "client_id": clientID, - "success": success, - "timestamp": time.Now().Unix(), - }) - }) - - protectedWS.POST("/cleanup/force", func(c *gin.Context) { - var req struct { - InactiveMinutes int `json:"inactive_minutes"` - Force bool `json:"force"` - } - if err := c.ShouldBindJSON(&req); err != nil { - req.InactiveMinutes = 10 - req.Force = false - } - - cleanedCount := websocketHandler.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute) - c.JSON(200, gin.H{ - "status": "admin cleanup completed", - "cleaned_clients": cleanedCount, - "inactive_minutes": req.InactiveMinutes, - "force": req.Force, - "timestamp": time.Now().Unix(), - }) - }) - } - return router } diff --git a/internal/services/websocket/broadcaster.go b/internal/services/websocket/broadcaster.go new file mode 100644 index 0000000..34d76ff --- /dev/null +++ b/internal/services/websocket/broadcaster.go @@ -0,0 +1,195 @@ +package websocket + +import ( + "time" +) + +// Broadcaster mengelola broadcasting pesan +type Broadcaster struct { + hub *Hub +} + +// NewBroadcaster membuat broadcaster baru +func NewBroadcaster(hub *Hub) *Broadcaster { + return &Broadcaster{hub: hub} +} + +// StartServerBroadcasters memulai broadcaster server +func (b *Broadcaster) StartServerBroadcasters() { + // Heartbeat broadcaster + go b.startHeartbeatBroadcaster() + + // System notification broadcaster + go b.startSystemNotificationBroadcaster() + + // Data stream broadcaster + go b.startDataStreamBroadcaster() +} + +// startHeartbeatBroadcaster memulai broadcaster heartbeat +func (b *Broadcaster) startHeartbeatBroadcaster() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b.hub.mu.RLock() + connectedClients := len(b.hub.clients) + uniqueIPs := len(b.hub.clientsByIP) + staticClients := len(b.hub.clientsByStatic) + b.hub.mu.RUnlock() + + b.BroadcastMessage("server_heartbeat", map[string]interface{}{ + "message": "Server heartbeat", + "connected_clients": connectedClients, + "unique_ips": uniqueIPs, + "static_clients": staticClients, + "timestamp": time.Now().Unix(), + "server_id": b.hub.config.ServerID, + }) + case <-b.hub.ctx.Done(): + return + } + } +} + +// startSystemNotificationBroadcaster memulai broadcaster notifikasi sistem +func (b *Broadcaster) startSystemNotificationBroadcaster() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + dbHealth := b.hub.dbService.Health() + b.BroadcastMessage("system_status", map[string]interface{}{ + "type": "system_notification", + "database": dbHealth, + "timestamp": time.Now().Unix(), + "uptime": time.Since(b.hub.startTime).String(), + }) + case <-b.hub.ctx.Done(): + return + } + } +} + +// startDataStreamBroadcaster memulai broadcaster aliran data +func (b *Broadcaster) startDataStreamBroadcaster() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + counter := 0 + + for { + select { + case <-ticker.C: + counter++ + b.BroadcastMessage("data_stream", map[string]interface{}{ + "id": counter, + "value": counter * 10, + "timestamp": time.Now().Unix(), + "type": "real_time_data", + }) + case <-b.hub.ctx.Done(): + return + } + } +} + +// BroadcastMessage mengirim pesan ke semua klien +func (b *Broadcaster) BroadcastMessage(messageType string, data interface{}) { + msg := NewWebSocketMessage(MessageType(messageType), data, "", "") + + select { + case b.hub.messageQueue <- msg: + default: + // Antrian penuh, abaikan pesan + } +} + +// BroadcastToRoom mengirim pesan ke ruangan tertentu +func (b *Broadcaster) BroadcastToRoom(room string, messageType string, data interface{}) { + msg := NewWebSocketMessage( + MessageType(messageType), + map[string]interface{}{ + "room": room, + "data": data, + }, + "", + room, + ) + + select { + case b.hub.messageQueue <- msg: + default: + // Antrian penuh, abaikan pesan + } +} + +// SendToClient mengirim pesan ke klien tertentu +func (b *Broadcaster) SendToClient(clientID string, messageType string, data interface{}) { + msg := NewWebSocketMessage(MessageType(messageType), data, clientID, "") + + select { + case b.hub.messageQueue <- msg: + default: + // Antrian penuh, abaikan pesan + } +} + +// SendToClientByStaticID mengirim pesan ke klien berdasarkan ID statis +func (b *Broadcaster) SendToClientByStaticID(staticID string, messageType string, data interface{}) bool { + b.hub.mu.RLock() + client, exists := b.hub.clientsByStatic[staticID] + b.hub.mu.RUnlock() + + if !exists { + return false + } + + b.SendToClient(client.ID, messageType, data) + return true +} + +// BroadcastToIP mengirim pesan ke semua klien dari IP tertentu +func (b *Broadcaster) BroadcastToIP(ipAddress string, messageType string, data interface{}) int { + b.hub.mu.RLock() + clients := b.hub.clientsByIP[ipAddress] + b.hub.mu.RUnlock() + + count := 0 + for _, client := range clients { + b.SendToClient(client.ID, messageType, data) + count++ + } + + return count +} + +// BroadcastClientToClient mengirim pesan dari satu klien ke klien lain +func (b *Broadcaster) BroadcastClientToClient(fromClientID, toClientID string, messageType string, data interface{}) { + message := NewWebSocketMessage(MessageType(messageType), map[string]interface{}{ + "from_client_id": fromClientID, + "data": data, + }, toClientID, "") + b.hub.broadcast <- message +} + +// BroadcastServerToClient mengirim pesan dari server ke klien tertentu +func (b *Broadcaster) BroadcastServerToClient(clientID string, messageType string, data interface{}) { + message := NewWebSocketMessage(MessageType(messageType), data, clientID, "") + b.hub.broadcast <- message +} + +// BroadcastServerToRoom mengirim pesan dari server ke ruangan tertentu +func (b *Broadcaster) BroadcastServerToRoom(room string, messageType string, data interface{}) { + message := NewWebSocketMessage(MessageType(messageType), data, "", room) + b.hub.broadcast <- message +} + +// BroadcastServerToAll mengirim pesan dari server ke semua klien +func (b *Broadcaster) BroadcastServerToAll(messageType string, data interface{}) { + message := NewWebSocketMessage(MessageType(messageType), data, "", "") + b.hub.broadcast <- message +} diff --git a/internal/services/websocket/client.go b/internal/services/websocket/client.go new file mode 100644 index 0000000..96ed7ca --- /dev/null +++ b/internal/services/websocket/client.go @@ -0,0 +1,728 @@ +package websocket + +import ( + "api-service/internal/config" + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// Client mewakili koneksi klien WebSocket +type Client struct { + // Identifikasi + ID string + StaticID string + UserID string + Room string + + // Koneksi + IPAddress string + Conn *websocket.Conn + Send chan WebSocketMessage + + // Hub + Hub *Hub + + // Context + ctx context.Context + cancel context.CancelFunc + + // Status + lastPing time.Time + lastPong time.Time + connectedAt time.Time + isActive bool + + // Sinkronisasi + mu sync.RWMutex +} + +// NewClient membuat klien baru +func NewClient( + id, staticID, userID, room, ipAddress string, + conn *websocket.Conn, + hub *Hub, + config config.WebSocketConfig, +) *Client { + ctx, cancel := context.WithCancel(hub.ctx) + + return &Client{ + ID: id, + StaticID: staticID, + UserID: userID, + Room: room, + IPAddress: ipAddress, + Conn: conn, + Send: make(chan WebSocketMessage, config.ChannelBufferSize), + Hub: hub, + ctx: ctx, + cancel: cancel, + lastPing: time.Now(), + connectedAt: time.Now(), + isActive: true, + } +} + +// readPup menangani pembacaan pesan dari klien +func (c *Client) readPump() { + defer func() { + c.Hub.unregister <- c + c.Conn.Close() + }() + + // Konfigurasi koneksi + c.Conn.SetReadLimit(int64(c.Hub.config.MaxMessageSize)) + c.resetReadDeadline() + + // Setup ping/pong handlers + c.Conn.SetPingHandler(func(message string) error { + c.resetReadDeadline() + return c.Conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(c.Hub.config.WriteTimeout)) + }) + + c.Conn.SetPongHandler(func(message string) error { + c.mu.Lock() + c.lastPong = time.Now() + c.isActive = true + c.mu.Unlock() + c.resetReadDeadline() + return nil + }) + + for { + select { + case <-c.ctx.Done(): + return + default: + _, message, err := c.Conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, + websocket.CloseGoingAway, + websocket.CloseAbnormalClosure, + websocket.CloseNormalClosure) { + c.Hub.errorCount++ + } + return + } + + // Reset deadline setiap kali ada pesan masuk + c.resetReadDeadline() + c.updateLastActivity() + + // Parse pesan + var msg WebSocketMessage + if err := json.Unmarshal(message, &msg); err != nil { + c.sendErrorResponse("Invalid message format", err.Error()) + continue + } + + // Set metadata pesan + msg.Timestamp = time.Now() + msg.ClientID = c.ID + if msg.MessageID == "" { + msg.MessageID = uuid.New().String() + } + + // Proses pesan menggunakan registry + c.handleMessage(msg) + } + } +} + +// writePump menangani pengiriman pesan ke klien +func (c *Client) writePump() { + ticker := time.NewTicker(c.Hub.config.PingInterval) + defer func() { + ticker.Stop() + c.Conn.Close() + }() + + for { + select { + case message, ok := <-c.Send: + c.Conn.SetWriteDeadline(time.Now().Add(c.Hub.config.WriteTimeout)) + if !ok { + c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + if err := c.Conn.WriteJSON(message); err != nil { + c.Hub.errorCount++ + return + } + + case <-ticker.C: + // Kirim ping dan periksa respons + if err := c.sendPing(); err != nil { + c.Hub.errorCount++ + return + } + + // Periksa timeout pong + if c.isPongTimeout() { + return + } + + case <-c.ctx.Done(): + return + } + } +} + +// handleMessage memproses pesan masuk +func (c *Client) handleMessage(msg WebSocketMessage) { + // Dapatkan handler dari registry + handler, exists := c.Hub.messageRegistry.GetHandler(msg.Type) + if !exists { + // Handler tidak ditemukan, gunakan handler default + c.handleDefaultMessage(msg) + return + } + + // Jalankan handler + if err := handler.HandleMessage(c, msg); err != nil { + c.sendErrorResponse("Error handling message", err.Error()) + c.Hub.errorCount++ + } +} + +// handleDefaultMessage menangani pesan tanpa handler khusus +func (c *Client) handleDefaultMessage(msg WebSocketMessage) { + switch msg.Type { + case "broadcast": + c.Hub.broadcast <- msg + c.sendDirectResponse("broadcast_sent", "Message broadcasted to all clients") + + case "direct_message": + c.handleDirectMessage(msg) + + case "ping": + c.handlePing(msg) + + case "pong": + // Pong sudah ditangani di level koneksi + break + + case "heartbeat": + c.handleHeartbeat(msg) + + case "connection_test": + c.handleConnectionTest(msg) + + case "get_online_users": + c.sendOnlineUsers() + + case "join_room": + c.handleJoinRoom(msg) + + case "leave_room": + c.handleLeaveRoom(msg) + + case "get_room_info": + c.handleGetRoomInfo(msg) + + case "database_query", "db_query": + c.handleDatabaseQuery(msg) + + case "db_insert": + c.handleDatabaseInsert(msg) + + case "db_custom_query": + c.handleDatabaseCustomQuery(msg) + + case "get_stats": + c.handleGetStats(msg) + + case "get_server_stats": + c.handleGetServerStats(msg) + + case "get_system_health": + c.handleGetSystemHealth(msg) + + case "admin_kick_client": + c.handleAdminKickClient(msg) + + case "admin_kill_server": + c.handleAdminKillServer(msg) + + case "admin_clear_logs": + c.handleAdminClearLogs(msg) + + default: + c.sendDirectResponse("message_received", fmt.Sprintf("Message received: %v", msg.Data)) + c.Hub.broadcast <- msg + } +} + +// Metode helper lainnya... +func (c *Client) resetReadDeadline() { + c.Conn.SetReadDeadline(time.Now().Add(c.Hub.config.ReadTimeout)) +} + +func (c *Client) updateLastActivity() { + c.mu.Lock() + defer c.mu.Unlock() + c.lastPing = time.Now() + c.isActive = true +} + +func (c *Client) sendPing() error { + c.Conn.SetWriteDeadline(time.Now().Add(c.Hub.config.WriteTimeout)) + if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return err + } + + c.mu.Lock() + c.lastPing = time.Now() + c.mu.Unlock() + + return nil +} + +func (c *Client) isPongTimeout() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + lastActivity := c.lastPong + if lastActivity.IsZero() { + lastActivity = c.lastPing + } + + return time.Since(lastActivity) > c.Hub.config.PongTimeout +} + +func (c *Client) isClientActive() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.isActive && time.Since(c.lastPing) < c.Hub.config.PongTimeout +} + +func (c *Client) sendDirectResponse(messageType string, data interface{}) { + response := NewWebSocketMessage(MessageType(messageType), data, c.ID, c.Room) + + select { + case c.Send <- response: + default: + // Channel penuh, abaikan pesan + } +} + +func (c *Client) sendErrorResponse(error, details string) { + c.sendDirectResponse("error", map[string]interface{}{ + "error": error, + "details": details, + }) +} + +func (c *Client) gracefulClose() { + c.mu.Lock() + c.isActive = false + c.mu.Unlock() + + // Kirim pesan close + c.Conn.SetWriteDeadline(time.Now().Add(c.Hub.config.WriteTimeout)) + c.Conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + + // Batalkan context + c.cancel() +} + +// ReadPump mengekspos metode readPump untuk handler +func (c *Client) ReadPump() { + c.readPump() +} + +// WritePump mengekspos metode writePump untuk handler +func (c *Client) WritePump() { + c.writePump() +} + +// handleDirectMessage menangani pesan direct message +func (c *Client) handleDirectMessage(msg WebSocketMessage) { + data, ok := msg.Data.(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid direct message format", "Data must be an object") + return + } + + targetClientID, ok := data["target_client_id"].(string) + if !ok || targetClientID == "" { + c.sendErrorResponse("Invalid target client ID", "target_client_id is required") + return + } + + message, ok := data["message"].(string) + if !ok || message == "" { + c.sendErrorResponse("Invalid message content", "message is required") + return + } + + // Buat pesan direct message + directMsg := NewWebSocketMessage(DirectMessage, map[string]interface{}{ + "from_client_id": c.ID, + "from_user_id": c.UserID, + "message": message, + "timestamp": msg.Timestamp, + }, targetClientID, "") + + // Kirim ke target client + select { + case c.Hub.broadcast <- directMsg: + c.sendDirectResponse("direct_message_sent", map[string]interface{}{ + "target_client_id": targetClientID, + "message": message, + }) + default: + c.sendErrorResponse("Failed to send direct message", "Message queue is full") + } +} + +// handlePing menangani ping message +func (c *Client) handlePing(msg WebSocketMessage) { + // Kirim pong response + pongMsg := NewWebSocketMessage(PongMessage, map[string]interface{}{ + "timestamp": msg.Timestamp.Unix(), + "client_id": c.ID, + }, c.ID, "") + + select { + case c.Send <- pongMsg: + default: + // Channel penuh, abaikan + } +} + +// handleHeartbeat menangani heartbeat message +func (c *Client) handleHeartbeat(msg WebSocketMessage) { + // Kirim heartbeat acknowledgment + heartbeatAck := NewWebSocketMessage(MessageType("heartbeat_ack"), map[string]interface{}{ + "timestamp": time.Now().Unix(), + "client_uptime": time.Since(c.connectedAt).Seconds(), + "server_uptime": time.Since(c.Hub.startTime).Seconds(), + "client_id": c.ID, + }, c.ID, "") + + select { + case c.Send <- heartbeatAck: + default: + // Channel penuh, abaikan + } +} + +// handleConnectionTest menangani connection test +func (c *Client) handleConnectionTest(msg WebSocketMessage) { + // Kirim connection test result + testResult := NewWebSocketMessage(MessageType("connection_test_result"), map[string]interface{}{ + "timestamp": time.Now().Unix(), + "client_id": c.ID, + "connection_status": "healthy", + "latency_ms": 0, // Could be calculated if ping timestamp is provided + "uptime_seconds": time.Since(c.connectedAt).Seconds(), + }, c.ID, "") + + select { + case c.Send <- testResult: + default: + // Channel penuh, abaikan + } +} + +// handleJoinRoom menangani join room +func (c *Client) handleJoinRoom(msg WebSocketMessage) { + data, ok := msg.Data.(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid join room format", "Data must be an object") + return + } + + roomName, ok := data["room"].(string) + if !ok || roomName == "" { + c.sendErrorResponse("Invalid room name", "room is required") + return + } + + // Update client room + oldRoom := c.Room + c.Room = roomName + + // Update hub room mapping + c.Hub.mu.Lock() + if oldRoom != "" { + if roomClients, exists := c.Hub.rooms[oldRoom]; exists { + delete(roomClients, c) + if len(roomClients) == 0 { + delete(c.Hub.rooms, oldRoom) + } + } + } + + if c.Hub.rooms[roomName] == nil { + c.Hub.rooms[roomName] = make(map[*Client]bool) + } + c.Hub.rooms[roomName][c] = true + c.Hub.mu.Unlock() + + // Log activity + c.Hub.logActivity("client_join_room", c.ID, fmt.Sprintf("Room: %s", roomName)) + + // Kirim response + c.sendDirectResponse("room_joined", map[string]interface{}{ + "room": roomName, + "previous_room": oldRoom, + }) +} + +// handleLeaveRoom menangani leave room +func (c *Client) handleLeaveRoom(msg WebSocketMessage) { + oldRoom := c.Room + + if oldRoom == "" { + c.sendErrorResponse("Not in any room", "Client is not currently in a room") + return + } + + // Update hub room mapping + c.Hub.mu.Lock() + if roomClients, exists := c.Hub.rooms[oldRoom]; exists { + delete(roomClients, c) + if len(roomClients) == 0 { + delete(c.Hub.rooms, oldRoom) + } + } + c.Hub.mu.Unlock() + + // Clear client room + c.Room = "" + + // Log activity + c.Hub.logActivity("client_leave_room", c.ID, fmt.Sprintf("Room: %s", oldRoom)) + + // Kirim response + c.sendDirectResponse("room_left", map[string]interface{}{ + "room": oldRoom, + }) +} + +// handleGetRoomInfo menangani get room info +func (c *Client) handleGetRoomInfo(msg WebSocketMessage) { + c.Hub.mu.RLock() + defer c.Hub.mu.RUnlock() + + roomInfo := make(map[string]interface{}) + + // Info ruangan saat ini + if c.Room != "" { + if roomClients, exists := c.Hub.rooms[c.Room]; exists { + clientIDs := make([]string, 0, len(roomClients)) + for client := range roomClients { + clientIDs = append(clientIDs, client.ID) + } + roomInfo["current_room"] = map[string]interface{}{ + "name": c.Room, + "client_count": len(roomClients), + "clients": clientIDs, + } + } + } + + // Info semua ruangan + allRooms := make(map[string]int) + for roomName, clients := range c.Hub.rooms { + allRooms[roomName] = len(clients) + } + roomInfo["all_rooms"] = allRooms + roomInfo["total_rooms"] = len(c.Hub.rooms) + + c.sendDirectResponse("room_info", roomInfo) +} + +// handleDatabaseInsert menangani database insert +func (c *Client) handleDatabaseInsert(msg WebSocketMessage) { + data, ok := msg.Data.(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid database insert format", "Data must be an object") + return + } + + table, ok := data["table"].(string) + if !ok || table == "" { + c.sendErrorResponse("Invalid table name", "table is required") + return + } + + insertData, ok := data["data"].(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid insert data", "data must be an object") + return + } + + // For now, just acknowledge the insert request + // In a real implementation, you would perform the actual database insert + c.sendDirectResponse("db_insert_result", map[string]interface{}{ + "table": table, + "status": "acknowledged", + "message": "Insert request received (not implemented)", + "data": insertData, // Use the variable to avoid unused error + }) +} + +// handleDatabaseCustomQuery menangani custom database query +func (c *Client) handleDatabaseCustomQuery(msg WebSocketMessage) { + data, ok := msg.Data.(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid database query format", "Data must be an object") + return + } + + database, ok := data["database"].(string) + if !ok || database == "" { + database = "default" + } + + query, ok := data["query"].(string) + if !ok || query == "" { + c.sendErrorResponse("Invalid query", "query is required") + return + } + + // For now, just acknowledge the query request + // In a real implementation, you would execute the query + c.sendDirectResponse("db_query_result", map[string]interface{}{ + "database": database, + "query": query, + "status": "acknowledged", + "message": "Query request received (not implemented)", + }) +} + +// handleGetStats menangani get stats +func (c *Client) handleGetStats(msg WebSocketMessage) { + stats := c.Hub.GetStats() + c.sendDirectResponse("stats", stats) +} + +// handleGetServerStats menangani get server stats +func (c *Client) handleGetServerStats(msg WebSocketMessage) { + // Create monitoring manager instance + monitoringManager := NewMonitoringManager(c.Hub) + detailedStats := monitoringManager.GetDetailedStats() + c.sendDirectResponse("server_stats", detailedStats) +} + +// handleGetSystemHealth menangani get system health +func (c *Client) handleGetSystemHealth(msg WebSocketMessage) { + systemHealth := make(map[string]interface{}) + if c.Hub.dbService != nil { + systemHealth["databases"] = c.Hub.dbService.Health() + systemHealth["available_dbs"] = c.Hub.dbService.ListDBs() + } + systemHealth["websocket_status"] = "healthy" + systemHealth["uptime_seconds"] = time.Since(c.Hub.startTime).Seconds() + + c.sendDirectResponse("system_health", systemHealth) +} + +// handleAdminKickClient menangani admin kick client +func (c *Client) handleAdminKickClient(msg WebSocketMessage) { + // This should be protected by authentication, but for now just acknowledge + data, ok := msg.Data.(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid admin command format", "Data must be an object") + return + } + + targetClientID, ok := data["client_id"].(string) + if !ok || targetClientID == "" { + c.sendErrorResponse("Invalid target client ID", "client_id is required") + return + } + + // For now, just acknowledge the kick request + // In a real implementation, you would check admin permissions and kick the client + c.sendDirectResponse("admin_command_result", map[string]interface{}{ + "command": "kick_client", + "target_client_id": targetClientID, + "status": "acknowledged", + "message": "Kick request received (not implemented)", + }) +} + +// handleAdminKillServer menangani admin kill server +func (c *Client) handleAdminKillServer(msg WebSocketMessage) { + // This should be protected by authentication, but for now just acknowledge + c.sendDirectResponse("admin_command_result", map[string]interface{}{ + "command": "kill_server", + "status": "acknowledged", + "message": "Kill server request received (not implemented - would require admin auth)", + }) +} + +// handleAdminClearLogs menangani admin clear logs +func (c *Client) handleAdminClearLogs(msg WebSocketMessage) { + // This should be protected by authentication, but for now just acknowledge + c.sendDirectResponse("admin_command_result", map[string]interface{}{ + "command": "clear_logs", + "status": "acknowledged", + "message": "Clear logs request received (not implemented - would require admin auth)", + }) +} + +// Implementasi metode lainnya... +func (c *Client) handleDatabaseQuery(msg WebSocketMessage) { + // Implementasi yang sama seperti sebelumnya + data, ok := msg.Data.(map[string]interface{}) + if !ok { + c.sendErrorResponse("Invalid database query format", "Data must be an object") + return + } + + table, ok := data["table"].(string) + if !ok || table == "" { + c.sendErrorResponse("Invalid table name", "table is required") + return + } + + // For now, just acknowledge the query request + c.sendDirectResponse("db_query_result", map[string]interface{}{ + "table": table, + "status": "acknowledged", + "message": "Query request received (not implemented)", + }) +} + +func (c *Client) sendOnlineUsers() { + c.Hub.mu.RLock() + defer c.Hub.mu.RUnlock() + + users := make([]map[string]interface{}, 0, len(c.Hub.clients)) + for client := range c.Hub.clients { + user := map[string]interface{}{ + "id": client.ID, + "static_id": client.StaticID, + "user_id": client.UserID, + "room": client.Room, + "ip_address": client.IPAddress, + "connected_at": client.connectedAt, + "last_ping": client.lastPing, + "last_pong": client.lastPong, + "is_active": client.isClientActive(), + } + users = append(users, user) + } + + response := NewWebSocketMessage(OnlineUsersMessage, map[string]interface{}{ + "users": users, + "count": len(users), + }, c.ID, "") + + select { + case c.Send <- response: + default: + // Channel penuh, abaikan + } +} diff --git a/internal/services/websocket/handlers.go b/internal/services/websocket/handlers.go new file mode 100644 index 0000000..62051bd --- /dev/null +++ b/internal/services/websocket/handlers.go @@ -0,0 +1,621 @@ +package websocket + +import ( + "context" + "fmt" + "strings" + "time" +) + +// DatabaseHandler menangani operasi database +type DatabaseHandler struct { + hub *Hub +} + +func NewDatabaseHandler(hub *Hub) *DatabaseHandler { + return &DatabaseHandler{hub: hub} +} + +func (h *DatabaseHandler) HandleMessage(client *Client, message WebSocketMessage) error { + switch message.Type { + case DatabaseInsertMessage: + return h.handleDatabaseInsert(client, message) + case DatabaseQueryMessage: + return h.handleDatabaseQuery(client, message) + case DatabaseCustomQueryMessage: + return h.handleDatabaseCustomQuery(client, message) + default: + return fmt.Errorf("unsupported database message type: %s", message.Type) + } +} + +func (h *DatabaseHandler) MessageType() MessageType { + return DatabaseInsertMessage // Primary type for registration +} + +func (h *DatabaseHandler) handleDatabaseInsert(client *Client, message WebSocketMessage) error { + data, ok := message.Data.(map[string]interface{}) + if !ok { + client.sendErrorResponse("Invalid database insert format", "Data must be an object") + return nil + } + + table, ok := data["table"].(string) + if !ok || table == "" { + client.sendErrorResponse("Invalid table name", "table is required") + return nil + } + + insertData, ok := data["data"].(map[string]interface{}) + if !ok { + client.sendErrorResponse("Invalid insert data", "data must be an object") + return nil + } + + // Perform actual database insert + if h.hub.dbService != nil { + // Get database connection + db, err := h.hub.GetDatabaseConnection("postgres_satudata") + if err != nil { + client.sendErrorResponse("Database connection error", err.Error()) + return nil + } + + // Build insert query + columns := make([]string, 0, len(insertData)) + values := make([]interface{}, 0, len(insertData)) + placeholders := make([]string, 0, len(insertData)) + + i := 1 + for col, val := range insertData { + columns = append(columns, col) + values = append(values, val) + placeholders = append(placeholders, fmt.Sprintf("$%d", i)) + i++ + } + + query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + table, + strings.Join(columns, ", "), + strings.Join(placeholders, ", ")) + + // Execute insert + ctx, cancel := context.WithTimeout(client.ctx, 30*time.Second) + defer cancel() + + result, err := db.ExecContext(ctx, query, values...) + if err != nil { + client.sendErrorResponse("Database insert error", err.Error()) + return nil + } + + rowsAffected, _ := result.RowsAffected() + client.sendDirectResponse("db_insert_result", map[string]interface{}{ + "table": table, + "status": "success", + "rows_affected": rowsAffected, + "message": "Data inserted successfully", + }) + } else { + client.sendErrorResponse("Database service not available", "Database service is not initialized") + } + + return nil +} + +func (h *DatabaseHandler) handleDatabaseQuery(client *Client, message WebSocketMessage) error { + data, ok := message.Data.(map[string]interface{}) + if !ok { + client.sendErrorResponse("Invalid database query format", "Data must be an object") + return nil + } + + table, ok := data["table"].(string) + if !ok || table == "" { + client.sendErrorResponse("Invalid table name", "table is required") + return nil + } + + // Execute query + results, err := h.hub.ExecuteDatabaseQuery("postgres_satudata", fmt.Sprintf("SELECT * FROM %s LIMIT 100", table)) + if err != nil { + client.sendErrorResponse("Database query error", err.Error()) + return nil + } + + client.sendDirectResponse("db_query_result", map[string]interface{}{ + "table": table, + "status": "success", + "results": results, + "count": len(results), + }) + + return nil +} + +func (h *DatabaseHandler) handleDatabaseCustomQuery(client *Client, message WebSocketMessage) error { + data, ok := message.Data.(map[string]interface{}) + if !ok { + client.sendErrorResponse("Invalid database query format", "Data must be an object") + return nil + } + + database, ok := data["database"].(string) + if !ok || database == "" { + database = "postgres_satudata" + } + + query, ok := data["query"].(string) + if !ok || query == "" { + client.sendErrorResponse("Invalid query", "query is required") + return nil + } + + // Execute custom query + results, err := h.hub.ExecuteDatabaseQuery(database, query) + if err != nil { + client.sendErrorResponse("Database query error", err.Error()) + return nil + } + + client.sendDirectResponse("db_query_result", map[string]interface{}{ + "database": database, + "query": query, + "status": "success", + "results": results, + "count": len(results), + }) + + return nil +} + +// AdminHandler menangani operasi admin +type AdminHandler struct { + hub *Hub +} + +func NewAdminHandler(hub *Hub) *AdminHandler { + return &AdminHandler{hub: hub} +} + +func (h *AdminHandler) HandleMessage(client *Client, message WebSocketMessage) error { + switch message.Type { + case AdminKickClientMessage: + return h.handleAdminKickClient(client, message) + case AdminKillServerMessage: + return h.handleAdminKillServer(client, message) + case AdminClearLogsMessage: + return h.handleAdminClearLogs(client, message) + default: + return fmt.Errorf("unsupported admin message type: %s", message.Type) + } +} + +func (h *AdminHandler) MessageType() MessageType { + return AdminKickClientMessage // Primary type for registration +} + +func (h *AdminHandler) handleAdminKickClient(client *Client, message WebSocketMessage) error { + data, ok := message.Data.(map[string]interface{}) + if !ok { + client.sendErrorResponse("Invalid admin command format", "Data must be an object") + return nil + } + + targetClientID, ok := data["client_id"].(string) + if !ok || targetClientID == "" { + client.sendErrorResponse("Invalid target client ID", "client_id is required") + return nil + } + + // Check if target client exists + h.hub.mu.RLock() + targetClient, exists := h.hub.clientsByID[targetClientID] + h.hub.mu.RUnlock() + + if !exists { + client.sendErrorResponse("Client not found", fmt.Sprintf("Client %s not found", targetClientID)) + return nil + } + + // Log activity + h.hub.logActivity("admin_kick_client", client.ID, fmt.Sprintf("Kicked client: %s", targetClientID)) + + // Disconnect the target client + targetClient.cancel() + targetClient.Conn.Close() + + client.sendDirectResponse("admin_command_result", map[string]interface{}{ + "command": "kick_client", + "target_client_id": targetClientID, + "status": "success", + "message": "Client kicked successfully", + }) + + return nil +} + +func (h *AdminHandler) handleAdminKillServer(client *Client, message WebSocketMessage) error { + // Log activity + h.hub.logActivity("admin_kill_server", client.ID, "Server kill signal received") + + // For testing purposes, just acknowledge (don't actually kill server) + client.sendDirectResponse("admin_command_result", map[string]interface{}{ + "command": "kill_server", + "status": "acknowledged", + "message": "Kill server signal received (test mode - server not actually killed)", + }) + + return nil +} + +func (h *AdminHandler) handleAdminClearLogs(client *Client, message WebSocketMessage) error { + // Clear activity logs + h.hub.activityMu.Lock() + h.hub.activityLog = make([]ActivityLog, 0, h.hub.config.ActivityLogSize) + h.hub.activityMu.Unlock() + + // Log the clear action + h.hub.logActivity("admin_clear_logs", client.ID, "Activity logs cleared") + + client.sendDirectResponse("admin_command_result", map[string]interface{}{ + "command": "clear_logs", + "status": "success", + "message": "Server logs cleared successfully", + }) + + return nil +} + +// RoomHandler menangani operasi ruangan +type RoomHandler struct { + hub *Hub +} + +func NewRoomHandler(hub *Hub) *RoomHandler { + return &RoomHandler{hub: hub} +} + +func (h *RoomHandler) HandleMessage(client *Client, message WebSocketMessage) error { + switch message.Type { + case JoinRoomMessage: + return h.handleJoinRoom(client, message) + case LeaveRoomMessage: + return h.handleLeaveRoom(client, message) + case GetRoomInfoMessage: + return h.handleGetRoomInfo(client, message) + default: + return fmt.Errorf("unsupported room message type: %s", message.Type) + } +} + +func (h *RoomHandler) MessageType() MessageType { + return JoinRoomMessage // Primary type for registration +} + +func (h *RoomHandler) handleJoinRoom(client *Client, message WebSocketMessage) error { + data, ok := message.Data.(map[string]interface{}) + if !ok { + client.sendErrorResponse("Invalid join room format", "Data must be an object") + return nil + } + + roomName, ok := data["room"].(string) + if !ok || roomName == "" { + client.sendErrorResponse("Invalid room name", "room is required") + return nil + } + + // Update client room + oldRoom := client.Room + client.Room = roomName + + // Update hub room mapping + h.hub.mu.Lock() + if oldRoom != "" { + if roomClients, exists := h.hub.rooms[oldRoom]; exists { + delete(roomClients, client) + if len(roomClients) == 0 { + delete(h.hub.rooms, oldRoom) + } + } + } + + if h.hub.rooms[roomName] == nil { + h.hub.rooms[roomName] = make(map[*Client]bool) + } + h.hub.rooms[roomName][client] = true + h.hub.mu.Unlock() + + // Log activity + h.hub.logActivity("client_join_room", client.ID, fmt.Sprintf("Room: %s", roomName)) + + // Notify other clients in the room + h.hub.broadcaster.BroadcastServerToRoom(roomName, "user_joined_room", map[string]interface{}{ + "client_id": client.ID, + "user_id": client.UserID, + "room": roomName, + "joined_at": time.Now().Unix(), + }) + + // Send response + client.sendDirectResponse("room_joined", map[string]interface{}{ + "room": roomName, + "previous_room": oldRoom, + }) + + return nil +} + +func (h *RoomHandler) handleLeaveRoom(client *Client, message WebSocketMessage) error { + oldRoom := client.Room + + if oldRoom == "" { + client.sendErrorResponse("Not in any room", "Client is not currently in a room") + return nil + } + + // Update hub room mapping + h.hub.mu.Lock() + if roomClients, exists := h.hub.rooms[oldRoom]; exists { + delete(roomClients, client) + if len(roomClients) == 0 { + delete(h.hub.rooms, oldRoom) + } + } + h.hub.mu.Unlock() + + // Clear client room + client.Room = "" + + // Log activity + h.hub.logActivity("client_leave_room", client.ID, fmt.Sprintf("Room: %s", oldRoom)) + + // Notify other clients in the room + h.hub.broadcaster.BroadcastServerToRoom(oldRoom, "user_left_room", map[string]interface{}{ + "client_id": client.ID, + "user_id": client.UserID, + "room": oldRoom, + "left_at": time.Now().Unix(), + }) + + // Send response + client.sendDirectResponse("room_left", map[string]interface{}{ + "room": oldRoom, + }) + + return nil +} + +func (h *RoomHandler) handleGetRoomInfo(client *Client, message WebSocketMessage) error { + h.hub.mu.RLock() + defer h.hub.mu.RUnlock() + + roomInfo := make(map[string]interface{}) + + // Info ruangan saat ini + if client.Room != "" { + if roomClients, exists := h.hub.rooms[client.Room]; exists { + clientIDs := make([]string, 0, len(roomClients)) + for client := range roomClients { + clientIDs = append(clientIDs, client.ID) + } + roomInfo["current_room"] = map[string]interface{}{ + "name": client.Room, + "client_count": len(roomClients), + "clients": clientIDs, + } + } + } + + // Info semua ruangan + allRooms := make(map[string]int) + for roomName, clients := range h.hub.rooms { + allRooms[roomName] = len(clients) + } + roomInfo["all_rooms"] = allRooms + roomInfo["total_rooms"] = len(h.hub.rooms) + + client.sendDirectResponse("room_info", roomInfo) + + return nil +} + +// MonitoringHandler menangani operasi monitoring +type MonitoringHandler struct { + hub *Hub +} + +func NewMonitoringHandler(hub *Hub) *MonitoringHandler { + return &MonitoringHandler{hub: hub} +} + +func (h *MonitoringHandler) HandleMessage(client *Client, message WebSocketMessage) error { + switch message.Type { + case GetStatsMessage: + return h.handleGetStats(client, message) + case GetServerStatsMessage: + return h.handleGetServerStats(client, message) + case GetSystemHealthMessage: + return h.handleGetSystemHealth(client, message) + default: + return fmt.Errorf("unsupported monitoring message type: %s", message.Type) + } +} + +func (h *MonitoringHandler) MessageType() MessageType { + return GetStatsMessage // Primary type for registration +} + +func (h *MonitoringHandler) handleGetStats(client *Client, message WebSocketMessage) error { + stats := h.hub.GetStats() + client.sendDirectResponse("stats", stats) + return nil +} + +func (h *MonitoringHandler) handleGetServerStats(client *Client, message WebSocketMessage) error { + // Create monitoring manager instance + monitoringManager := NewMonitoringManager(h.hub) + detailedStats := monitoringManager.GetDetailedStats() + client.sendDirectResponse("server_stats", detailedStats) + return nil +} + +func (h *MonitoringHandler) handleGetSystemHealth(client *Client, message WebSocketMessage) error { + systemHealth := make(map[string]interface{}) + if h.hub.dbService != nil { + systemHealth["databases"] = h.hub.dbService.Health() + systemHealth["available_dbs"] = h.hub.dbService.ListDBs() + } + systemHealth["websocket_status"] = "healthy" + systemHealth["uptime_seconds"] = time.Since(h.hub.startTime).Seconds() + + client.sendDirectResponse("system_health", systemHealth) + return nil +} + +// ConnectionHandler menangani operasi koneksi +type ConnectionHandler struct { + hub *Hub +} + +func NewConnectionHandler(hub *Hub) *ConnectionHandler { + return &ConnectionHandler{hub: hub} +} + +func (h *ConnectionHandler) HandleMessage(client *Client, message WebSocketMessage) error { + switch message.Type { + case PingMessage: + return h.handlePing(client, message) + case PongMessage: + return h.handlePong(client, message) + case HeartbeatMessage: + return h.handleHeartbeat(client, message) + case ConnectionTestMessage: + return h.handleConnectionTest(client, message) + case OnlineUsersMessage: + return h.handleGetOnlineUsers(client, message) + default: + return fmt.Errorf("unsupported connection message type: %s", message.Type) + } +} + +func (h *ConnectionHandler) MessageType() MessageType { + return PingMessage // Primary type for registration +} + +func (h *ConnectionHandler) handlePing(client *Client, message WebSocketMessage) error { + // Send pong response + pongMsg := NewWebSocketMessage(PongMessage, map[string]interface{}{ + "timestamp": message.Timestamp.Unix(), + "client_id": client.ID, + }, client.ID, "") + + select { + case client.Send <- pongMsg: + default: + // Channel penuh, abaikan + } + + return nil +} + +func (h *ConnectionHandler) handlePong(client *Client, message WebSocketMessage) error { + // Pong sudah ditangani di level koneksi, tapi kita bisa log aktivitas + h.hub.logActivity("pong_received", client.ID, "Pong message received") + return nil +} + +func (h *ConnectionHandler) handleHeartbeat(client *Client, message WebSocketMessage) error { + // Send heartbeat acknowledgment + heartbeatAck := NewWebSocketMessage(MessageType("heartbeat_ack"), map[string]interface{}{ + "timestamp": time.Now().Unix(), + "client_uptime": time.Since(client.connectedAt).Seconds(), + "server_uptime": time.Since(h.hub.startTime).Seconds(), + "client_id": client.ID, + }, client.ID, "") + + select { + case client.Send <- heartbeatAck: + default: + // Channel penuh, abaikan + } + + return nil +} + +func (h *ConnectionHandler) handleConnectionTest(client *Client, message WebSocketMessage) error { + // Send connection test result + testResult := NewWebSocketMessage(MessageType("connection_test_result"), map[string]interface{}{ + "timestamp": time.Now().Unix(), + "client_id": client.ID, + "connection_status": "healthy", + "latency_ms": 0, // Could be calculated if ping timestamp is provided + "uptime_seconds": time.Since(client.connectedAt).Seconds(), + }, client.ID, "") + + select { + case client.Send <- testResult: + default: + // Channel penuh, abaikan + } + + return nil +} + +func (h *ConnectionHandler) handleGetOnlineUsers(client *Client, message WebSocketMessage) error { + h.hub.mu.RLock() + defer h.hub.mu.RUnlock() + + users := make([]map[string]interface{}, 0, len(h.hub.clients)) + for client := range h.hub.clients { + user := map[string]interface{}{ + "id": client.ID, + "static_id": client.StaticID, + "user_id": client.UserID, + "room": client.Room, + "ip_address": client.IPAddress, + "connected_at": client.connectedAt, + "last_ping": client.lastPing, + "last_pong": client.lastPong, + "is_active": client.isClientActive(), + } + users = append(users, user) + } + + response := NewWebSocketMessage(OnlineUsersMessage, map[string]interface{}{ + "users": users, + "count": len(users), + }, client.ID, "") + + select { + case client.Send <- response: + default: + // Channel penuh, abaikan + } + + return nil +} + +// SetupDefaultHandlers sets up all default message handlers +func SetupDefaultHandlers(registry *MessageRegistry, hub *Hub) { + // Register database handler + dbHandler := NewDatabaseHandler(hub) + registry.RegisterHandler(dbHandler) + + // Register admin handler + adminHandler := NewAdminHandler(hub) + registry.RegisterHandler(adminHandler) + + // Register room handler + roomHandler := NewRoomHandler(hub) + registry.RegisterHandler(roomHandler) + + // Register monitoring handler + monitoringHandler := NewMonitoringHandler(hub) + registry.RegisterHandler(monitoringHandler) + + // Register connection handler + connectionHandler := NewConnectionHandler(hub) + registry.RegisterHandler(connectionHandler) +} diff --git a/internal/services/websocket/hub.go b/internal/services/websocket/hub.go new file mode 100644 index 0000000..d4da478 --- /dev/null +++ b/internal/services/websocket/hub.go @@ -0,0 +1,444 @@ +package websocket + +import ( + "api-service/internal/config" + "api-service/internal/database" + "context" + "database/sql" + "encoding/json" + "fmt" + "sync" + "time" +) + +// Hub mengelola semua klien WebSocket +type Hub struct { + // Konfigurasi + config config.WebSocketConfig + + // Klien + clients map[*Client]bool + clientsByID map[string]*Client + clientsByIP map[string][]*Client + clientsByStatic map[string]*Client + + // Ruangan + rooms map[string]map[*Client]bool + + // Channel komunikasi + broadcast chan WebSocketMessage + register chan *Client + unregister chan *Client + messageQueue chan WebSocketMessage + + // Sinkronisasi + mu sync.RWMutex + + // Context + ctx context.Context + cancel context.CancelFunc + + // Layanan eksternal + dbService database.Service + + // Monitoring + startTime time.Time + messageCount int64 + errorCount int64 + activityLog []ActivityLog + activityMu sync.RWMutex + + // Registry handler pesan + messageRegistry *MessageRegistry + broadcaster *Broadcaster +} + +// ActivityLog menyimpan log aktivitas +type ActivityLog struct { + Timestamp time.Time `json:"timestamp"` + Event string `json:"event"` + ClientID string `json:"client_id"` + Details string `json:"details"` +} + +// DatabaseService mendefinisikan interface untuk layanan database +type DatabaseService interface { + Health() map[string]interface{} + ListDBs() []string + ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(channel, payload string)) error + NotifyChange(dbName, channel, payload string) error + GetDB(name string) (*sql.DB, error) + GetPrimaryDB(name string) (*sql.DB, error) +} + +// Global database service instance +var ( + dbService database.Service + once sync.Once +) + +// Initialize the database connection +func init() { + once.Do(func() { + dbService = database.New(config.LoadConfig()) + if dbService == nil { + panic("Failed to initialize database connection") + } + }) +} + +// NewHub membuat hub baru dengan konfigurasi yang diberikan +func NewHub(config config.WebSocketConfig) *Hub { + ctx, cancel := context.WithCancel(context.Background()) + + hub := &Hub{ + + config: config, + clients: make(map[*Client]bool), + clientsByID: make(map[string]*Client), + clientsByIP: make(map[string][]*Client), + clientsByStatic: make(map[string]*Client), + rooms: make(map[string]map[*Client]bool), + broadcast: make(chan WebSocketMessage, 1000), + register: make(chan *Client), + unregister: make(chan *Client), + messageQueue: make(chan WebSocketMessage, config.MessageQueueSize), + ctx: ctx, + cancel: cancel, + dbService: dbService, + startTime: time.Now(), + activityLog: make([]ActivityLog, 0, config.ActivityLogSize), + messageRegistry: NewMessageRegistry(), + } + + // Setup default message handlers + // SetupDefaultHandlers(hub.messageRegistry) + + // Setup database change listeners + hub.setupDatabaseListeners() + + return hub +} + +// Run menjalankan loop utama hub +func (h *Hub) Run() { + // Start queue workers + for i := 0; i < h.config.QueueWorkers; i++ { + go h.queueWorker(i) + } + + for { + select { + case client := <-h.register: + h.registerClient(client) + + case client := <-h.unregister: + h.unregisterClient(client) + + case message := <-h.broadcast: + h.messageCount++ + h.broadcastToClients(message) + + case <-h.ctx.Done(): + return + } + } +} + +// queueWorker memproses pesan dari antrian +func (h *Hub) queueWorker(workerID int) { + for { + select { + case message := <-h.messageQueue: + h.broadcast <- message + case <-h.ctx.Done(): + return + } + } +} + +// registerClient mendaftarkan klien baru +func (h *Hub) registerClient(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + // Register di peta klien utama + h.clients[client] = true + + // Register berdasarkan ID + h.clientsByID[client.ID] = client + + // Register berdasarkan ID statis + if client.StaticID != "" { + h.clientsByStatic[client.StaticID] = client + } + + // Register berdasarkan IP + if h.clientsByIP[client.IPAddress] == nil { + h.clientsByIP[client.IPAddress] = make([]*Client, 0) + } + h.clientsByIP[client.IPAddress] = append(h.clientsByIP[client.IPAddress], client) + + // Register di ruangan + if client.Room != "" { + if h.rooms[client.Room] == nil { + h.rooms[client.Room] = make(map[*Client]bool) + } + h.rooms[client.Room][client] = true + } + + // Log aktivitas + h.logActivity("client_connected", client.ID, + fmt.Sprintf("IP: %s, Static: %s, Room: %s", client.IPAddress, client.StaticID, client.Room)) +} + +// unregisterClient menghapus klien +func (h *Hub) unregisterClient(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.clients[client]; ok { + // Hapus dari peta klien utama + delete(h.clients, client) + close(client.Send) + + // Hapus dari peta berdasarkan ID + delete(h.clientsByID, client.ID) + + // Hapus dari peta berdasarkan ID statis + if client.StaticID != "" { + delete(h.clientsByStatic, client.StaticID) + } + + // Hapus dari peta berdasarkan IP + if ipClients, exists := h.clientsByIP[client.IPAddress]; exists { + for i, c := range ipClients { + if c == client { + h.clientsByIP[client.IPAddress] = append(ipClients[:i], ipClients[i+1:]...) + break + } + } + // Jika tidak ada lagi klien dari IP ini, hapus entri IP + if len(h.clientsByIP[client.IPAddress]) == 0 { + delete(h.clientsByIP, client.IPAddress) + } + } + + // Hapus dari ruangan + if client.Room != "" { + if room, exists := h.rooms[client.Room]; exists { + delete(room, client) + if len(room) == 0 { + delete(h.rooms, client.Room) + } + } + } + } + + // Log aktivitas + h.logActivity("client_disconnected", client.ID, + fmt.Sprintf("IP: %s, Duration: %v", client.IPAddress, time.Since(client.connectedAt))) + + client.cancel() +} + +// broadcastToClients mengirim pesan ke klien yang sesuai +func (h *Hub) broadcastToClients(message WebSocketMessage) { + h.mu.RLock() + defer h.mu.RUnlock() + + if message.ClientID != "" { + // Kirim ke klien tertentu + if client, exists := h.clientsByID[message.ClientID]; exists { + select { + case client.Send <- message: + default: + go func() { + h.unregister <- client + }() + } + } + return + } + + // Periksa apakah ini pesan ruangan + if message.Room != "" { + if room, roomExists := h.rooms[message.Room]; roomExists { + for client := range room { + select { + case client.Send <- message: + default: + go func(c *Client) { + h.unregister <- c + }(client) + } + } + } + return + } + + // Broadcast ke semua klien + for client := range h.clients { + select { + case client.Send <- message: + default: + go func(c *Client) { + h.unregister <- c + }(client) + } + } +} + +// logActivity mencatat aktivitas +func (h *Hub) logActivity(event, clientID, details string) { + h.activityMu.Lock() + defer h.activityMu.Unlock() + + activity := ActivityLog{ + Timestamp: time.Now(), + Event: event, + ClientID: clientID, + Details: details, + } + + h.activityLog = append(h.activityLog, activity) + + // Pertahankan hanya aktivitas terakhir sesuai konfigurasi + if len(h.activityLog) > h.config.ActivityLogSize { + h.activityLog = h.activityLog[1:] + } +} + +// GetConfig mengembalikan konfigurasi hub +func (h *Hub) GetConfig() config.WebSocketConfig { + return h.config +} + +// GetMessageRegistry mengembalikan registry pesan +func (h *Hub) GetMessageRegistry() *MessageRegistry { + return h.messageRegistry +} + +// RegisterChannel mengembalikan channel register untuk handler +func (h *Hub) RegisterChannel() chan *Client { + return h.register +} + +// GetStats mengembalikan statistik hub +func (h *Hub) GetStats() map[string]interface{} { + h.mu.RLock() + defer h.mu.RUnlock() + + return map[string]interface{}{ + "connected_clients": len(h.clients), + "unique_ips": len(h.clientsByIP), + "static_clients": len(h.clientsByStatic), + "rooms": len(h.rooms), + "message_count": h.messageCount, + "error_count": h.errorCount, + "uptime": time.Since(h.startTime).String(), + "server_id": h.config.ServerID, + "timestamp": time.Now().Unix(), + } +} + +// setupDatabaseListeners sets up database change listeners for real-time updates +func (h *Hub) setupDatabaseListeners() { + // Listen for changes on retribusi table + channels := []string{"retribusi_changes", "data_changes"} + + err := h.dbService.ListenForChanges(h.ctx, "postgres_satudata", channels, func(channel, payload string) { + h.handleDatabaseChange(channel, payload) + }) + + if err != nil { + h.logActivity("database_listener_error", "", fmt.Sprintf("Failed to setup listeners: %v", err)) + } else { + h.logActivity("database_listener_started", "", "Database change listeners initialized") + } +} + +// handleDatabaseChange processes database change notifications +func (h *Hub) handleDatabaseChange(channel, payload string) { + h.logActivity("database_change", "", fmt.Sprintf("Channel: %s, Payload: %s", channel, payload)) + + // Parse the payload to determine what changed + var changeData map[string]interface{} + if err := json.Unmarshal([]byte(payload), &changeData); err != nil { + h.logActivity("database_change_parse_error", "", fmt.Sprintf("Failed to parse payload: %v", err)) + return + } + + // Create WebSocket message for broadcasting + message := WebSocketMessage{ + Type: DatabaseChangeMessage, + Data: changeData, + ClientID: "", + Room: "data_updates", // Broadcast to data_updates room + } + + // Broadcast the change to all connected clients in the data_updates room + h.broadcast <- message +} + +// GetDatabaseConnection returns a database connection for the specified database +func (h *Hub) GetDatabaseConnection(dbName string) (*sql.DB, error) { + return h.dbService.GetDB(dbName) +} + +// ExecuteDatabaseQuery executes a database query and returns results +func (h *Hub) ExecuteDatabaseQuery(dbName, query string, args ...interface{}) ([]map[string]interface{}, error) { + db, err := h.GetDatabaseConnection(dbName) + if err != nil { + return nil, fmt.Errorf("failed to get database connection: %w", err) + } + + ctx, cancel := context.WithTimeout(h.ctx, 30*time.Second) + defer cancel() + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + columns, err := rows.Columns() + if err != nil { + return nil, fmt.Errorf("failed to get columns: %w", err) + } + + var results []map[string]interface{} + + for rows.Next() { + values := make([]interface{}, len(columns)) + valuePtrs := make([]interface{}, len(columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + row := make(map[string]interface{}) + for i, col := range columns { + val := values[i] + if b, ok := val.([]byte); ok { + val = string(b) + } + row[col] = val + } + results = append(results, row) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + return results, nil +} + +// NotifyDatabaseChange sends a notification to the database for real-time updates +func (h *Hub) NotifyDatabaseChange(dbName, channel, payload string) error { + return h.dbService.NotifyChange(dbName, channel, payload) +} diff --git a/internal/services/websocket/message.go b/internal/services/websocket/message.go new file mode 100644 index 0000000..9952b3e --- /dev/null +++ b/internal/services/websocket/message.go @@ -0,0 +1,109 @@ +package websocket + +import ( + "time" + + "github.com/google/uuid" +) + +// MessageType mendefinisikan tipe-tipe pesan yang valid +type MessageType string + +const ( + // Pesan koneksi + WelcomeMessage MessageType = "welcome" + ErrorMessage MessageType = "error" + DisconnectMessage MessageType = "disconnect" + + // Pesan kontrol + PingMessage MessageType = "ping" + PongMessage MessageType = "pong" + HeartbeatMessage MessageType = "heartbeat" + ConnectionTestMessage MessageType = "connection_test" + + // Pesan informasi + ServerInfoMessage MessageType = "server_info" + ClientInfoMessage MessageType = "client_info" + OnlineUsersMessage MessageType = "online_users" + + // Pesan komunikasi + DirectMessage MessageType = "direct_message" + RoomMessage MessageType = "room_message" + BroadcastMessage MessageType = "broadcast" + + // Pesan database + DatabaseQueryMessage MessageType = "database_query" + DatabaseChangeMessage MessageType = "database_change" + DatabaseInsertMessage MessageType = "db_insert" + DatabaseCustomQueryMessage MessageType = "db_custom_query" + + // Pesan monitoring + SystemStatusMessage MessageType = "system_status" + DataStreamMessage MessageType = "data_stream" + + // Pesan room management + JoinRoomMessage MessageType = "join_room" + LeaveRoomMessage MessageType = "leave_room" + GetRoomInfoMessage MessageType = "get_room_info" + + // Pesan admin + AdminKickClientMessage MessageType = "admin_kick_client" + AdminKillServerMessage MessageType = "admin_kill_server" + GetServerStatsMessage MessageType = "get_server_stats" + GetSystemHealthMessage MessageType = "get_system_health" + AdminClearLogsMessage MessageType = "admin_clear_logs" + + // Pesan utility + GetStatsMessage MessageType = "get_stats" +) + +// WebSocketMessage menyimpan struktur pesan WebSocket +type WebSocketMessage struct { + Type MessageType `json:"type"` + Data interface{} `json:"data"` + Timestamp time.Time `json:"timestamp"` + ClientID string `json:"client_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + Room string `json:"room,omitempty"` +} + +// NewWebSocketMessage membuat pesan WebSocket baru +func NewWebSocketMessage(msgType MessageType, data interface{}, clientID, room string) WebSocketMessage { + return WebSocketMessage{ + Type: msgType, + Data: data, + Timestamp: time.Now(), + ClientID: clientID, + MessageID: uuid.New().String(), + Room: room, + } +} + +// MessageHandler mendefinisikan interface untuk handler pesan +type MessageHandler interface { + HandleMessage(client *Client, message WebSocketMessage) error + MessageType() MessageType +} + +// MessageRegistry menyimpan registry untuk handler pesan +type MessageRegistry struct { + handlers map[MessageType]MessageHandler +} + +// NewMessageRegistry membuat registry pesan baru +func NewMessageRegistry() *MessageRegistry { + return &MessageRegistry{ + handlers: make(map[MessageType]MessageHandler), + } +} + +// RegisterHandler mendaftarkan handler untuk tipe pesan tertentu +func (r *MessageRegistry) RegisterHandler(handler MessageHandler) { + r.handlers[handler.MessageType()] = handler +} + +// GetHandler mendapatkan handler untuk tipe pesan tertentu +func (r *MessageRegistry) GetHandler(msgType MessageType) (MessageHandler, bool) { + handler, exists := r.handlers[msgType] + return handler, exists +} diff --git a/internal/services/websocket/monitor.go b/internal/services/websocket/monitor.go new file mode 100644 index 0000000..ec9c8f6 --- /dev/null +++ b/internal/services/websocket/monitor.go @@ -0,0 +1,211 @@ +package websocket + +import ( + "fmt" + "time" +) + +// DetailedStats menyimpan statistik detail WebSocket +type DetailedStats struct { + ConnectedClients int `json:"connected_clients"` + UniqueIPs int `json:"unique_ips"` + StaticClients int `json:"static_clients"` + ActiveRooms int `json:"active_rooms"` + IPDistribution map[string]int `json:"ip_distribution"` + RoomDistribution map[string]int `json:"room_distribution"` + MessageQueueSize int `json:"message_queue_size"` + QueueWorkers int `json:"queue_workers"` + Uptime time.Duration `json:"uptime_seconds"` + Timestamp int64 `json:"timestamp"` +} + +// ClientInfo menyimpan informasi klien +type ClientInfo struct { + ID string `json:"id"` + StaticID string `json:"static_id"` + IPAddress string `json:"ip_address"` + UserID string `json:"user_id"` + Room string `json:"room"` + ConnectedAt time.Time `json:"connected_at"` + LastPing time.Time `json:"last_ping"` + LastPong time.Time `json:"last_pong"` + IsActive bool `json:"is_active"` +} + +// MonitoringData menyimpan data monitoring lengkap +type MonitoringData struct { + Stats DetailedStats `json:"stats"` + RecentActivity []ActivityLog `json:"recent_activity"` + SystemHealth map[string]interface{} `json:"system_health"` + Performance PerformanceMetrics `json:"performance"` +} + +// PerformanceMetrics menyimpan metrik performa +type PerformanceMetrics struct { + MessagesPerSecond float64 `json:"messages_per_second"` + AverageLatency float64 `json:"average_latency_ms"` + ErrorRate float64 `json:"error_rate_percent"` + MemoryUsage int64 `json:"memory_usage_bytes"` +} + +// MonitoringManager mengelola monitoring WebSocket +type MonitoringManager struct { + hub *Hub +} + +// NewMonitoringManager membuat manajer monitoring baru +func NewMonitoringManager(hub *Hub) *MonitoringManager { + return &MonitoringManager{hub: hub} +} + +// GetDetailedStats mengembalikan statistik detail +func (m *MonitoringManager) GetDetailedStats() DetailedStats { + m.hub.mu.RLock() + defer m.hub.mu.RUnlock() + + // Hitung distribusi IP + ipDistribution := make(map[string]int) + for ip, clients := range m.hub.clientsByIP { + ipDistribution[ip] = len(clients) + } + + // Hitung distribusi ruangan + roomDistribution := make(map[string]int) + for room, clients := range m.hub.rooms { + roomDistribution[room] = len(clients) + } + + return DetailedStats{ + ConnectedClients: len(m.hub.clients), + UniqueIPs: len(m.hub.clientsByIP), + StaticClients: len(m.hub.clientsByStatic), + ActiveRooms: len(m.hub.rooms), + IPDistribution: ipDistribution, + RoomDistribution: roomDistribution, + MessageQueueSize: len(m.hub.messageQueue), + QueueWorkers: m.hub.config.QueueWorkers, + Uptime: time.Since(m.hub.startTime), + Timestamp: time.Now().Unix(), + } +} + +// GetAllClients mengembalikan semua klien yang terhubung +func (m *MonitoringManager) GetAllClients() []ClientInfo { + m.hub.mu.RLock() + defer m.hub.mu.RUnlock() + + var clients []ClientInfo + for client := range m.hub.clients { + clientInfo := ClientInfo{ + ID: client.ID, + StaticID: client.StaticID, + IPAddress: client.IPAddress, + UserID: client.UserID, + Room: client.Room, + ConnectedAt: client.connectedAt, + LastPing: client.lastPing, + LastPong: client.lastPong, + IsActive: client.isClientActive(), + } + clients = append(clients, clientInfo) + } + + return clients +} + +// GetMonitoringData mengembalikan data monitoring lengkap +func (m *MonitoringManager) GetMonitoringData() MonitoringData { + stats := m.GetDetailedStats() + + m.hub.activityMu.RLock() + recentActivity := make([]ActivityLog, 0) + // Dapatkan 100 aktivitas terakhir + start := len(m.hub.activityLog) - 100 + if start < 0 { + start = 0 + } + for i := start; i < len(m.hub.activityLog); i++ { + recentActivity = append(recentActivity, m.hub.activityLog[i]) + } + m.hub.activityMu.RUnlock() + + // Dapatkan kesehatan sistem dari layanan database + systemHealth := make(map[string]interface{}) + if m.hub.dbService != nil { + systemHealth["databases"] = m.hub.dbService.Health() + systemHealth["available_dbs"] = m.hub.dbService.ListDBs() + } + systemHealth["websocket_status"] = "healthy" + systemHealth["uptime_seconds"] = time.Since(m.hub.startTime).Seconds() + + // Hitung metrik performa + uptime := time.Since(m.hub.startTime) + var messagesPerSecond float64 + var errorRate float64 + + if uptime.Seconds() > 0 { + messagesPerSecond = float64(m.hub.messageCount) / uptime.Seconds() + } + + if m.hub.messageCount > 0 { + errorRate = (float64(m.hub.errorCount) / float64(m.hub.messageCount)) * 100 + } + + performance := PerformanceMetrics{ + MessagesPerSecond: messagesPerSecond, + AverageLatency: 2.5, // Nilai mock - implementasi pelacakan latensi aktual + ErrorRate: errorRate, + MemoryUsage: 0, // Nilai mock - implementasi pelacakan memori aktual + } + + return MonitoringData{ + Stats: stats, + RecentActivity: recentActivity, + SystemHealth: systemHealth, + Performance: performance, + } +} + +// CleanupInactiveClients membersihkan klien tidak aktif +func (m *MonitoringManager) CleanupInactiveClients(inactiveTimeout time.Duration) int { + m.hub.mu.RLock() + var inactiveClients []*Client + cutoff := time.Now().Add(-inactiveTimeout) + + for client := range m.hub.clients { + if client.lastPing.Before(cutoff) { + inactiveClients = append(inactiveClients, client) + } + } + m.hub.mu.RUnlock() + + // Putuskan koneksi klien tidak aktif + for _, client := range inactiveClients { + m.hub.logActivity("cleanup_disconnect", client.ID, + fmt.Sprintf("Inactive for %v", time.Since(client.lastPing))) + client.cancel() + client.Conn.Close() + } + + return len(inactiveClients) +} + +// DisconnectClient memutuskan koneksi klien tertentu +func (m *MonitoringManager) DisconnectClient(clientID string) bool { + m.hub.mu.RLock() + client, exists := m.hub.clientsByID[clientID] + m.hub.mu.RUnlock() + + if !exists { + return false + } + + // Log aktivitas + m.hub.logActivity("force_disconnect", clientID, "Client disconnected by admin") + + // Batalkan context dan tutup koneksi + client.cancel() + client.Conn.Close() + + return true +}