perubahan

This commit is contained in:
meninjar
2025-10-24 12:33:10 +00:00
parent f1c2628ca8
commit 416d553a69
18 changed files with 2799 additions and 2707 deletions

View File

@@ -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"]

57
TODO.md Normal file
View File

@@ -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

BIN
api Executable file

Binary file not shown.

View File

@@ -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

View File

@@ -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)}`;

7
go.mod
View File

@@ -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

21
go.sum
View File

@@ -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=

View File

@@ -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,
}
}

View File

@@ -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
}
}
}()
}

View File

@@ -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))
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}