From cab7394b1f66bdc957d34c0487264d71015b700c Mon Sep 17 00:00:00 2001 From: y2keui Date: Sat, 6 Jun 2026 14:58:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EA=B4=80=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=BC=88=EB=8C=80=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 10 + .env.example | 3 + .env.nas.example | 3 + .gitignore | 9 +- Dockerfile | 20 + README.md | 194 +++++ apps/android/README.md | 29 + apps/android/app/build.gradle.kts | 21 + apps/android/app/src/main/AndroidManifest.xml | 33 + .../cargoradar/LocationUploadService.kt | 302 ++++++++ .../com/comtropy/cargoradar/MainActivity.kt | 222 ++++++ .../main/res/drawable/ic_stat_location.xml | 10 + .../app/src/main/res/values/strings.xml | 6 + .../app/src/main/res/values/styles.xml | 9 + apps/android/build.gradle.kts | 4 + apps/android/settings.gradle.kts | 18 + apps/server/data/.gitkeep | 1 + apps/server/package.json | 14 + apps/server/src/server.js | 573 ++++++++++++++ apps/web/public/app.js | 720 ++++++++++++++++++ apps/web/public/index.html | 103 +++ apps/web/public/styles.css | 594 +++++++++++++++ docker-compose.yml | 26 + docs/ARCHITECTURE.md | 29 + docs/SYNOLOGY.md | 108 +++ scripts/backup-runtime-data.ps1 | 24 + scripts/health-check.ps1 | 32 + scripts/package-nas-deploy.ps1 | 76 ++ scripts/post-demo-location.ps1 | 29 + 29 files changed, 3221 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.nas.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 apps/android/README.md create mode 100644 apps/android/app/build.gradle.kts create mode 100644 apps/android/app/src/main/AndroidManifest.xml create mode 100644 apps/android/app/src/main/java/com/comtropy/cargoradar/LocationUploadService.kt create mode 100644 apps/android/app/src/main/java/com/comtropy/cargoradar/MainActivity.kt create mode 100644 apps/android/app/src/main/res/drawable/ic_stat_location.xml create mode 100644 apps/android/app/src/main/res/values/strings.xml create mode 100644 apps/android/app/src/main/res/values/styles.xml create mode 100644 apps/android/build.gradle.kts create mode 100644 apps/android/settings.gradle.kts create mode 100644 apps/server/data/.gitkeep create mode 100644 apps/server/package.json create mode 100644 apps/server/src/server.js create mode 100644 apps/web/public/app.js create mode 100644 apps/web/public/index.html create mode 100644 apps/web/public/styles.css create mode 100644 docker-compose.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/SYNOLOGY.md create mode 100644 scripts/backup-runtime-data.ps1 create mode 100644 scripts/health-check.ps1 create mode 100644 scripts/package-nas-deploy.ps1 create mode 100644 scripts/post-demo-location.ps1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de9f331 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.vs +backups +tmp +runtime-data +apps/server/data/* +!apps/server/data/.gitkeep +node_modules +*.log +*.zip diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc82e30 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +CARGORADAR_PORT=8080 +ADMIN_TOKEN= +KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5 diff --git a/.env.nas.example b/.env.nas.example new file mode 100644 index 0000000..3ecceb3 --- /dev/null +++ b/.env.nas.example @@ -0,0 +1,3 @@ +CARGORADAR_PORT=18081 +ADMIN_TOKEN=change_this_admin_token +KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5 diff --git a/.gitignore b/.gitignore index 9491a2f..85c0c23 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ *.userosscache *.sln.docstates +# CargoRadar runtime files +.env +dist/ +runtime-data/ +apps/server/data/* +!apps/server/data/.gitkeep + # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -360,4 +367,4 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da17529 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine + +WORKDIR /app + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=8080 +ENV DATA_DIR=/data +ENV WEB_ROOT=/app/apps/web/public + +COPY apps/server/package.json apps/server/package.json +COPY apps/server/src apps/server/src +COPY apps/web/public apps/web/public + +RUN mkdir -p /data + +EXPOSE 8080 +VOLUME ["/data"] + +CMD ["node", "apps/server/src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..875cd9c --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# CargoRadar + +CargoRadar is an MVP for tracking freight drivers from an Android app and +monitoring their latest positions from a web control screen. + +## Current Shape + +- `apps/server`: Node.js API server with no external runtime dependencies. +- `apps/web`: Static control web UI served by the API server. +- `apps/android`: Kotlin Android driver-app skeleton. +- `runtime-data`: Docker runtime data directory, created on first run. + +## Run Locally + +If Node.js is installed: + +```powershell +node apps/server/src/server.js +``` + +Open: + +```text +http://localhost:8080 +``` + +The web screen has a `샘플 위치` button that posts a demo location with the +default `demo-token`. + +## Run With Docker + +```powershell +copy .env.example .env +docker compose up --build +``` + +Open: + +```text +http://localhost:8080 +``` + +On Synology NAS, the same `docker-compose.yml` can be used through Container +Manager or SSH. Keep `runtime-data` mounted so driver tokens and location +history survive container restarts. + +For NAS, use `.env.nas.example` as the starting point and prefer port `18081` +behind Synology Reverse Proxy. + +## Maintenance Scripts + +Check server status: + +```powershell +.\scripts\health-check.ps1 -BaseUrl "http://127.0.0.1:8080" +``` + +Send a demo location: + +```powershell +.\scripts\post-demo-location.ps1 -BaseUrl "http://127.0.0.1:8080" +``` + +Back up runtime data: + +```powershell +.\scripts\backup-runtime-data.ps1 +``` + +## Kakao Map + +The Kakao JavaScript key has a project default in the server config. You can +override it in `.env`: + +```text +KAKAO_JAVASCRIPT_KEY= +``` + +The REST API key and native app key are not needed for the current web map +screen. + +In Kakao Developers, register every web origin that will open the control +screen. Useful early entries are: + +```text +http://localhost:8080 +http://127.0.0.1:8080 +http://:8080 +https:// +``` + +If the origin is missing, Kakao's map SDK can return HTTP 403 and the control +screen will show a map-key/domain warning. + +## Device Token + +On first server start, the server creates: + +```text +runtime-data/drivers.json +``` + +The default driver is: + +```json +{ + "driverId": "demo-driver", + "name": "Demo Driver", + "vehicleNo": "SEOUL-12-3456", + "token": "demo-token", + "enabled": true +} +``` + +Change the token before real use. + +## API + +Driver management: + +```text +GET /api/v1/drivers +POST /api/v1/drivers +PATCH /api/v1/drivers/:driverId +DELETE /api/v1/drivers/:driverId +``` + +Upload a location: + +```powershell +$body = @{ + driverId = "demo-driver" + vehicleNo = "SEOUL-12-3456" + latitude = 37.5665 + longitude = 126.9780 + accuracy = 12 + speed = 42 + heading = 90 + recordedAt = (Get-Date).ToUniversalTime().ToString("o") +} | ConvertTo-Json + +Invoke-RestMethod ` + -Uri "http://localhost:8080/api/v1/locations" ` + -Method Post ` + -Headers @{ "X-Device-Token" = "demo-token" } ` + -ContentType "application/json" ` + -Body $body +``` + +Read latest locations: + +```text +GET /api/v1/locations/latest +``` + +Read history: + +```text +GET /api/v1/locations/history?driverId=demo-driver&limit=200 +``` + +Realtime events: + +```text +GET /api/v1/events +``` + +## Android + +Open `apps/android` in Android Studio and run the `app` module. + +- Emulator server URL: `http://10.0.2.2:8080` +- Real phone server URL: `http://:8080` +- Driver ID and device token: use the values from the web `기사` tab. + +For production, use HTTPS and disable Android cleartext traffic. + +## Operation Flow + +1. Start the server on a PC or Synology NAS. +2. Open the control web screen. +3. Register a driver in the `기사` tab. +4. Copy that driver's token into the Android app. +5. Start tracking from the Android app. +6. Confirm the latest location in the `위치` tab. + +## Next Steps + +- Replace JSONL storage with PostgreSQL/PostGIS when history queries become + important. +- Expand driver/vehicle management with search and editing. +- Add route playback and detailed location history filters. +- Add route, geofence, and delayed-reception alerts. +- Add Synology reverse proxy, HTTPS, and admin authentication for deployment. diff --git a/apps/android/README.md b/apps/android/README.md new file mode 100644 index 0000000..0b71389 --- /dev/null +++ b/apps/android/README.md @@ -0,0 +1,29 @@ +# CargoRadar Android + +This is the first driver-app skeleton. It uses a foreground service, Android's +native `LocationManager`, and `HttpURLConnection` so the MVP has no runtime +library dependencies beyond the Android Gradle/Kotlin plugins. + +## Build + +Open `apps/android` in Android Studio, sync Gradle, then run the `app` module. + +Current Gradle plugin choices: + +- Android Gradle Plugin: `9.2.0` +- Kotlin: `2.4.0` +- compile/target SDK: `36` + +## MVP behavior + +- The app asks for foreground location and notification permissions. +- Press `운행 시작` to start a location foreground service. +- The service uploads location JSON to `/api/v1/locations`. +- Failed uploads are queued locally and retried after the next successful send. +- Press `운행 종료` to stop location updates. + +For emulator testing, keep the default server URL `http://10.0.2.2:8080`. +For a real phone, use the NAS or PC LAN URL, for example +`http://192.168.0.10:8080`. + +Production builds should use HTTPS and disable cleartext traffic. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000..5cf8246 --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.comtropy.cargoradar" + compileSdk = 36 + + defaultConfig { + applicationId = "com.comtropy.cargoradar" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } +} + +kotlin { + jvmToolchain(17) +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3c892fc --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/java/com/comtropy/cargoradar/LocationUploadService.kt b/apps/android/app/src/main/java/com/comtropy/cargoradar/LocationUploadService.kt new file mode 100644 index 0000000..a990225 --- /dev/null +++ b/apps/android/app/src/main/java/com/comtropy/cargoradar/LocationUploadService.kt @@ -0,0 +1,302 @@ +package com.comtropy.cargoradar + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.IBinder +import android.util.Base64 +import java.net.HttpURLConnection +import java.net.URL +import java.time.Instant +import java.util.Locale +import java.util.concurrent.Executors + +class LocationUploadService : Service(), LocationListener { + private val uploadExecutor = Executors.newSingleThreadExecutor() + private lateinit var locationManager: LocationManager + + private var serverUrl: String = "" + private var driverId: String = "" + private var vehicleNo: String = "" + private var deviceToken: String = "" + + override fun onCreate() { + super.onCreate() + locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + stopTracking() + return START_NOT_STICKY + } + + ACTION_START -> { + serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty().trimEnd('/') + driverId = intent.getStringExtra(EXTRA_DRIVER_ID).orEmpty() + vehicleNo = intent.getStringExtra(EXTRA_VEHICLE_NO).orEmpty() + deviceToken = intent.getStringExtra(EXTRA_DEVICE_TOKEN).orEmpty() + + startAsForeground("Waiting for location") + startLocationUpdates() + return START_STICKY + } + } + + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + stopTracking() + uploadExecutor.shutdown() + super.onDestroy() + } + + override fun onLocationChanged(location: Location) { + updateNotification("Uploading location · ${location.provider}") + uploadLocation(location) + } + + @SuppressLint("MissingPermission") + private fun startLocationUpdates() { + if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + updateNotification("Location permission required") + return + } + + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + .filter { provider -> locationManager.isProviderEnabled(provider) } + + if (providers.isEmpty()) { + updateNotification("No location provider enabled") + return + } + + for (provider in providers) { + locationManager.requestLocationUpdates( + provider, + MIN_UPDATE_INTERVAL_MS, + MIN_UPDATE_DISTANCE_M, + this + ) + + locationManager.getLastKnownLocation(provider)?.let(::uploadLocation) + } + } + + private fun stopTracking() { + locationManager.removeUpdates(this) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun uploadLocation(location: Location) { + if (serverUrl.isBlank() || driverId.isBlank() || deviceToken.isBlank()) { + updateNotification("Server config required") + return + } + + val endpoint = "$serverUrl/api/v1/locations" + val payload = buildLocationPayload(location) + + uploadExecutor.execute { + val sent = sendPayload(endpoint, payload) + + if (sent) { + flushQueuedLocations(endpoint) + updateNotification("Last upload ok · ${Instant.now()}") + } else { + enqueueLocationPayload(payload) + updateNotification("Upload queued · ${queuedPayloads().size}") + } + } + } + + private fun buildLocationPayload(location: Location): String { + val speedKmh = if (location.hasSpeed()) location.speed.toDouble() * 3.6 else null + val heading = if (location.hasBearing()) location.bearing.toDouble() else null + val accuracy = if (location.hasAccuracy()) location.accuracy.toDouble() else null + val recordedAt = Instant.ofEpochMilli(location.time).toString() + + return "{" + + "\"driverId\":${json(driverId)}," + + "\"vehicleNo\":${json(vehicleNo)}," + + "\"latitude\":${number(location.latitude)}," + + "\"longitude\":${number(location.longitude)}," + + "\"accuracy\":${nullableNumber(accuracy)}," + + "\"speed\":${nullableNumber(speedKmh)}," + + "\"heading\":${nullableNumber(heading)}," + + "\"provider\":${json(location.provider.orEmpty())}," + + "\"recordedAt\":${json(recordedAt)}" + + "}" + } + + private fun sendPayload(endpoint: String, payload: String): Boolean { + return try { + val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 10000 + readTimeout = 10000 + doOutput = true + setRequestProperty("Content-Type", "application/json; charset=utf-8") + setRequestProperty("X-Device-Token", deviceToken) + } + + connection.outputStream.use { stream -> + stream.write(payload.toByteArray(Charsets.UTF_8)) + } + + val responseCode = connection.responseCode + connection.disconnect() + responseCode in 200..299 + } catch (_: Exception) { + false + } + } + + private fun flushQueuedLocations(endpoint: String) { + val queued = queuedPayloads() + if (queued.isEmpty()) return + + val remaining = mutableListOf() + + for (payload in queued) { + if (!sendPayload(endpoint, payload)) { + remaining.add(payload) + } + } + + saveQueuedPayloads(remaining) + } + + private fun enqueueLocationPayload(payload: String) { + val queued = queuedPayloads().toMutableList() + queued.add(payload) + + while (queued.size > MAX_QUEUED_LOCATIONS) { + queued.removeAt(0) + } + + saveQueuedPayloads(queued) + } + + private fun queuedPayloads(): List { + val encoded = getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE) + .getString(KEY_QUEUED_PAYLOADS, "") + .orEmpty() + + if (encoded.isBlank()) return emptyList() + + return encoded + .lineSequence() + .filter { it.isNotBlank() } + .mapNotNull { line -> + try { + String(Base64.decode(line, Base64.NO_WRAP), Charsets.UTF_8) + } catch (_: Exception) { + null + } + } + .toList() + } + + private fun saveQueuedPayloads(payloads: List) { + val encoded = payloads.joinToString("\n") { payload -> + Base64.encodeToString(payload.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + } + + getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE) + .edit() + .putString(KEY_QUEUED_PAYLOADS, encoded) + .apply() + } + + private fun startAsForeground(content: String) { + val notification = buildNotification(content) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun updateNotification(content: String) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(content)) + } + + private fun buildNotification(content: String): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + Notification.Builder(this) + } + + return builder + .setSmallIcon(R.drawable.ic_stat_location) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(content) + .setOngoing(true) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + + private fun json(value: String): String { + return "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + } + + private fun number(value: Double): String { + return String.format(Locale.US, "%.7f", value) + } + + private fun nullableNumber(value: Double?): String { + return value?.let { String.format(Locale.US, "%.3f", it) } ?: "null" + } + + companion object { + const val ACTION_START = "com.comtropy.cargoradar.action.START" + const val ACTION_STOP = "com.comtropy.cargoradar.action.STOP" + const val EXTRA_SERVER_URL = "server_url" + const val EXTRA_DRIVER_ID = "driver_id" + const val EXTRA_VEHICLE_NO = "vehicle_no" + const val EXTRA_DEVICE_TOKEN = "device_token" + + private const val CHANNEL_ID = "cargo_radar_location" + private const val NOTIFICATION_ID = 1201 + private const val MIN_UPDATE_INTERVAL_MS = 30_000L + private const val MIN_UPDATE_DISTANCE_M = 100f + private const val MAX_QUEUED_LOCATIONS = 500 + private const val PREFERENCES_NAME = "cargo_radar_location_service" + private const val KEY_QUEUED_PAYLOADS = "queued_payloads" + } +} diff --git a/apps/android/app/src/main/java/com/comtropy/cargoradar/MainActivity.kt b/apps/android/app/src/main/java/com/comtropy/cargoradar/MainActivity.kt new file mode 100644 index 0000000..4f45866 --- /dev/null +++ b/apps/android/app/src/main/java/com/comtropy/cargoradar/MainActivity.kt @@ -0,0 +1,222 @@ +package com.comtropy.cargoradar + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.text.InputType +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import kotlin.math.roundToInt + +class MainActivity : Activity() { + private lateinit var preferences: SharedPreferences + private lateinit var serverUrlInput: EditText + private lateinit var driverIdInput: EditText + private lateinit var vehicleNoInput: EditText + private lateinit var tokenInput: EditText + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + preferences = getSharedPreferences("cargo_radar_config", MODE_PRIVATE) + setContentView(buildContentView()) + } + + private fun buildContentView(): View { + val container = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(18), dp(20), dp(18)) + } + + val title = TextView(this).apply { + text = getString(R.string.app_name) + textSize = 24f + setTextColor(0xff17212b.toInt()) + setTypeface(typeface, android.graphics.Typeface.BOLD) + } + container.addView(title, matchWrap()) + + val subtitle = TextView(this).apply { + text = "운행 위치 업로드" + textSize = 14f + setTextColor(0xff657487.toInt()) + } + container.addView(subtitle, marginBottom(20)) + + serverUrlInput = input( + key = "server_url", + defaultValue = "http://10.0.2.2:8080", + hint = "서버 URL" + ) + driverIdInput = input("driver_id", "demo-driver", "기사 ID") + vehicleNoInput = input("vehicle_no", "SEOUL-12-3456", "차량 번호") + tokenInput = input("device_token", "demo-token", "기기 토큰") + + addField(container, "서버 URL", serverUrlInput) + addField(container, "기사 ID", driverIdInput) + addField(container, "차량 번호", vehicleNoInput) + addField(container, "기기 토큰", tokenInput) + + val permissionButton = Button(this).apply { + text = "권한 확인" + setOnClickListener { requestRuntimePermissions() } + } + container.addView(permissionButton, marginTop(8)) + + val startButton = Button(this).apply { + text = "운행 시작" + setOnClickListener { startTracking() } + } + container.addView(startButton, marginTop(8)) + + val stopButton = Button(this).apply { + text = "운행 종료" + setOnClickListener { stopTracking() } + } + container.addView(stopButton, marginTop(8)) + + val settingsButton = Button(this).apply { + text = "앱 위치 설정" + setOnClickListener { + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = android.net.Uri.parse("package:$packageName") + }) + } + } + container.addView(settingsButton, marginTop(8)) + + statusText = TextView(this).apply { + text = "대기 중" + textSize = 14f + setTextColor(0xff17212b.toInt()) + } + container.addView(statusText, marginTop(18)) + + return ScrollView(this).apply { + addView(container) + } + } + + private fun addField(container: LinearLayout, labelText: String, input: EditText) { + val label = TextView(this).apply { + text = labelText + textSize = 13f + setTextColor(0xff657487.toInt()) + setTypeface(typeface, android.graphics.Typeface.BOLD) + } + container.addView(label, marginTop(14)) + container.addView(input, matchWrap()) + } + + private fun input(key: String, defaultValue: String, hint: String): EditText { + return EditText(this).apply { + setText(preferences.getString(key, defaultValue)) + this.hint = hint + inputType = InputType.TYPE_CLASS_TEXT + singleLine = true + } + } + + private fun requestRuntimePermissions() { + val permissions = mutableListOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + + requestPermissions(permissions.toTypedArray(), REQUEST_PERMISSIONS) + } + + private fun startTracking() { + if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + requestRuntimePermissions() + statusText.text = "위치 권한이 필요합니다." + return + } + + saveConfig() + + val intent = Intent(this, LocationUploadService::class.java).apply { + action = LocationUploadService.ACTION_START + putExtra(LocationUploadService.EXTRA_SERVER_URL, serverUrlInput.text.toString().trim()) + putExtra(LocationUploadService.EXTRA_DRIVER_ID, driverIdInput.text.toString().trim()) + putExtra(LocationUploadService.EXTRA_VEHICLE_NO, vehicleNoInput.text.toString().trim()) + putExtra(LocationUploadService.EXTRA_DEVICE_TOKEN, tokenInput.text.toString().trim()) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + + statusText.text = "위치 업로드를 시작했습니다." + } + + private fun stopTracking() { + val intent = Intent(this, LocationUploadService::class.java).apply { + action = LocationUploadService.ACTION_STOP + } + startService(intent) + statusText.text = "위치 업로드를 종료했습니다." + } + + private fun saveConfig() { + preferences.edit() + .putString("server_url", serverUrlInput.text.toString().trim()) + .putString("driver_id", driverIdInput.text.toString().trim()) + .putString("vehicle_no", vehicleNoInput.text.toString().trim()) + .putString("device_token", tokenInput.text.toString().trim()) + .apply() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_PERMISSIONS && grantResults.any { it == PackageManager.PERMISSION_GRANTED }) { + statusText.text = "권한 확인 완료" + } + } + + private fun matchWrap(): LinearLayout.LayoutParams { + return LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + + private fun marginTop(topDp: Int): LinearLayout.LayoutParams { + return matchWrap().apply { + topMargin = dp(topDp) + } + } + + private fun marginBottom(bottomDp: Int): LinearLayout.LayoutParams { + return matchWrap().apply { + bottomMargin = dp(bottomDp) + } + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).roundToInt() + } + + companion object { + private const val REQUEST_PERMISSIONS = 1001 + } +} diff --git a/apps/android/app/src/main/res/drawable/ic_stat_location.xml b/apps/android/app/src/main/res/drawable/ic_stat_location.xml new file mode 100644 index 0000000..ea39ca8 --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_stat_location.xml @@ -0,0 +1,10 @@ + + + + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b6813a1 --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + CargoRadar Driver + CargoRadar 운행 추적 + 운행 위치 업로드 + diff --git a/apps/android/app/src/main/res/values/styles.xml b/apps/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..ce7bdb9 --- /dev/null +++ b/apps/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts new file mode 100644 index 0000000..ea7ed60 --- /dev/null +++ b/apps/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.2.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts new file mode 100644 index 0000000..485370b --- /dev/null +++ b/apps/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "CargoRadarAndroid" +include(":app") diff --git a/apps/server/data/.gitkeep b/apps/server/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/server/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..60fd956 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cargoradar/server", + "version": "0.1.0", + "private": true, + "description": "CargoRadar MVP API and static control web server.", + "type": "commonjs", + "scripts": { + "start": "node src/server.js", + "dev": "node src/server.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/apps/server/src/server.js b/apps/server/src/server.js new file mode 100644 index 0000000..748f1a2 --- /dev/null +++ b/apps/server/src/server.js @@ -0,0 +1,573 @@ +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); +const { randomUUID } = require("node:crypto"); +const { URL } = require("node:url"); + +const HOST = process.env.HOST || "0.0.0.0"; +const PORT = Number(process.env.PORT || 8080); +const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, "..", "data"); +const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "web", "public"); +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ""; +const DEFAULT_KAKAO_JAVASCRIPT_KEY = "ea26ed8ef3cf2960e9c36f1c161dadb5"; + +const DRIVERS_FILE = path.join(DATA_DIR, "drivers.json"); +const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json"); +const HISTORY_FILE = path.join(DATA_DIR, "locations.jsonl"); + +const sseClients = new Set(); + +ensureDataFiles(); + +let drivers = loadDrivers(); +let latestLocations = loadLatestLocations(); + +const server = http.createServer(async (req, res) => { + const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + + try { + addCorsHeaders(res); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/health") { + sendJson(res, 200, { + status: "ok", + service: "cargoradar-server", + time: new Date().toISOString() + }); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/config.js") { + sendJavaScript(res, 200, `window.CARGORADAR_CONFIG = ${JSON.stringify({ + kakaoJavaScriptKey: process.env.KAKAO_JAVASCRIPT_KEY || DEFAULT_KAKAO_JAVASCRIPT_KEY + })};\n`); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/api/v1/drivers") { + if (!authorizeAdmin(req, res)) return; + sendJson(res, 200, { + drivers: drivers.map(toDriverResponse).sort(compareDrivers) + }); + return; + } + + if (req.method === "POST" && requestUrl.pathname === "/api/v1/drivers") { + if (!authorizeAdmin(req, res)) return; + const body = await readJsonBody(req); + const driver = createDriver(body); + drivers.push(driver); + writeJsonAtomic(DRIVERS_FILE, drivers); + sendJson(res, 201, { driver: toDriverResponse(driver) }); + return; + } + + const driverMatch = requestUrl.pathname.match(/^\/api\/v1\/drivers\/([^/]+)$/); + if (driverMatch && req.method === "PATCH") { + if (!authorizeAdmin(req, res)) return; + const driverId = decodeURIComponent(driverMatch[1]); + const body = await readJsonBody(req); + const driver = updateDriver(driverId, body); + writeJsonAtomic(DRIVERS_FILE, drivers); + sendJson(res, 200, { driver: toDriverResponse(driver) }); + return; + } + + if (driverMatch && req.method === "DELETE") { + if (!authorizeAdmin(req, res)) return; + const driverId = decodeURIComponent(driverMatch[1]); + const driverIndex = drivers.findIndex((driver) => driver.driverId === driverId); + + if (driverIndex < 0) { + sendJson(res, 404, { error: "driver_not_found" }); + return; + } + + drivers.splice(driverIndex, 1); + delete latestLocations[driverId]; + writeJsonAtomic(DRIVERS_FILE, drivers); + writeJsonAtomic(LATEST_FILE, latestLocations); + broadcastEvent("drivers.updated", { drivers: drivers.map(toDriverResponse).sort(compareDrivers) }); + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/api/v1/locations/latest") { + if (!authorizeAdmin(req, res)) return; + sendJson(res, 200, { + locations: Object.values(latestLocations).sort(compareLatestLocations) + }); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/api/v1/locations/history") { + if (!authorizeAdmin(req, res)) return; + const driverId = requestUrl.searchParams.get("driverId"); + const limit = clampNumber(Number(requestUrl.searchParams.get("limit") || 200), 1, 1000); + sendJson(res, 200, { + locations: readHistory({ driverId, limit }) + }); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/api/v1/events") { + if (!authorizeAdmin(req, res)) return; + openEventStream(req, res); + return; + } + + if (req.method === "POST" && requestUrl.pathname === "/api/v1/locations") { + const body = await readJsonBody(req); + const driver = authorizeDevice(req, body.driverId); + + if (!driver) { + sendJson(res, 401, { error: "invalid_device_token" }); + return; + } + + if (body.driverId && body.driverId !== driver.driverId) { + sendJson(res, 403, { error: "driver_token_mismatch" }); + return; + } + + const location = createLocationRecord(body, driver); + latestLocations[location.driverId] = location; + + appendJsonLine(HISTORY_FILE, location); + writeJsonAtomic(LATEST_FILE, latestLocations); + broadcastEvent("location.updated", location); + + sendJson(res, 201, { location }); + return; + } + + if (req.method === "GET" || req.method === "HEAD") { + if (serveStaticFile(req, res, requestUrl.pathname)) return; + } + + sendJson(res, 404, { error: "not_found" }); + } catch (error) { + console.error(error); + sendJson(res, error.statusCode || 500, { + error: error.publicMessage || "internal_server_error" + }); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`CargoRadar server listening on http://${HOST}:${PORT}`); + console.log(`Serving web files from ${WEB_ROOT}`); +}); + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +function shutdown() { + for (const client of sseClients) { + client.end(); + } + server.close(() => process.exit(0)); +} + +function ensureDataFiles() { + fs.mkdirSync(DATA_DIR, { recursive: true }); + + if (!fs.existsSync(DRIVERS_FILE)) { + writeJsonAtomic(DRIVERS_FILE, [ + { + driverId: "demo-driver", + name: "Demo Driver", + vehicleNo: "SEOUL-12-3456", + phone: "", + token: "demo-token", + enabled: true + } + ]); + } + + if (!fs.existsSync(LATEST_FILE)) { + writeJsonAtomic(LATEST_FILE, {}); + } + + if (!fs.existsSync(HISTORY_FILE)) { + fs.writeFileSync(HISTORY_FILE, "", "utf8"); + } +} + +function loadDrivers() { + const parsed = readJsonFile(DRIVERS_FILE, []); + return Array.isArray(parsed) ? parsed : []; +} + +function createDriver(body) { + const driverId = normalizeDriverId(body.driverId) || generateDriverId(); + + if (drivers.some((driver) => driver.driverId === driverId)) { + validationError("driver_id_exists"); + } + + const name = String(body.name || "").trim(); + const vehicleNo = String(body.vehicleNo || "").trim(); + + if (!name) validationError("driver_name_required"); + if (!vehicleNo) validationError("vehicle_no_required"); + + const driver = { + driverId, + name, + vehicleNo, + phone: String(body.phone || "").trim(), + token: String(body.token || "").trim() || generateDeviceToken(), + enabled: body.enabled === undefined ? true : Boolean(body.enabled), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + broadcastEvent("drivers.updated", { drivers: [...drivers, driver].map(toDriverResponse).sort(compareDrivers) }); + return driver; +} + +function updateDriver(driverId, body) { + const driver = drivers.find((candidate) => candidate.driverId === driverId); + if (!driver) notFoundError("driver_not_found"); + + if (body.name !== undefined) { + const name = String(body.name || "").trim(); + if (!name) validationError("driver_name_required"); + driver.name = name; + } + + if (body.vehicleNo !== undefined) { + const vehicleNo = String(body.vehicleNo || "").trim(); + if (!vehicleNo) validationError("vehicle_no_required"); + driver.vehicleNo = vehicleNo; + } + + if (body.phone !== undefined) { + driver.phone = String(body.phone || "").trim(); + } + + if (body.enabled !== undefined) { + driver.enabled = Boolean(body.enabled); + } + + if (body.regenerateToken) { + driver.token = generateDeviceToken(); + } else if (body.token !== undefined) { + const token = String(body.token || "").trim(); + if (!token) validationError("driver_token_required"); + driver.token = token; + } + + driver.updatedAt = new Date().toISOString(); + broadcastEvent("drivers.updated", { drivers: drivers.map(toDriverResponse).sort(compareDrivers) }); + return driver; +} + +function loadLatestLocations() { + const parsed = readJsonFile(LATEST_FILE, {}); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; +} + +function readJsonFile(filePath, fallback) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +} + +function writeJsonAtomic(filePath, value) { + const tempPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fs.renameSync(tempPath, filePath); +} + +function appendJsonLine(filePath, value) { + fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body) + }); + res.end(body); +} + +function sendJavaScript(res, statusCode, body) { + res.writeHead(statusCode, { + "Content-Type": "application/javascript; charset=utf-8", + "Content-Length": Buffer.byteLength(body) + }); + res.end(body); +} + +function addCorsHeaders(res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Admin-Token,X-Device-Token"); +} + +function authorizeAdmin(req, res) { + if (!ADMIN_TOKEN) return true; + + const token = extractToken(req, "x-admin-token"); + const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + const queryToken = requestUrl.searchParams.get("adminToken") || ""; + + if (token === ADMIN_TOKEN || queryToken === ADMIN_TOKEN) return true; + + sendJson(res, 401, { error: "invalid_admin_token" }); + return false; +} + +function authorizeDevice(req, requestedDriverId) { + const token = extractToken(req, "x-device-token"); + if (!token) return null; + + return drivers.find((driver) => { + if (!driver.enabled) return false; + if (driver.token !== token) return false; + if (requestedDriverId && driver.driverId !== requestedDriverId) return false; + return true; + }) || null; +} + +function extractToken(req, headerName) { + const directToken = req.headers[headerName]; + if (typeof directToken === "string" && directToken.trim()) { + return directToken.trim(); + } + + const authorization = req.headers.authorization || ""; + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ""; +} + +async function readJsonBody(req) { + const chunks = []; + let totalBytes = 0; + + for await (const chunk of req) { + totalBytes += chunk.length; + if (totalBytes > 1024 * 1024) { + const error = new Error("Request body too large."); + error.statusCode = 413; + error.publicMessage = "body_too_large"; + throw error; + } + chunks.push(chunk); + } + + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); + } catch { + const error = new Error("Invalid JSON request body."); + error.statusCode = 400; + error.publicMessage = "invalid_json"; + throw error; + } +} + +function createLocationRecord(body, driver) { + const latitude = Number(body.latitude); + const longitude = Number(body.longitude); + + if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) { + validationError("invalid_latitude"); + } + + if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + validationError("invalid_longitude"); + } + + return { + id: randomUUID(), + driverId: driver.driverId, + driverName: driver.name || driver.driverId, + vehicleNo: String(body.vehicleNo || driver.vehicleNo || ""), + latitude, + longitude, + accuracy: optionalNumber(body.accuracy), + speed: optionalNumber(body.speed), + heading: optionalNumber(body.heading), + batteryLevel: optionalNumber(body.batteryLevel), + provider: body.provider ? String(body.provider) : "", + recordedAt: normalizeDate(body.recordedAt), + receivedAt: new Date().toISOString() + }; +} + +function validationError(publicMessage) { + const error = new Error(publicMessage); + error.statusCode = 400; + error.publicMessage = publicMessage; + throw error; +} + +function notFoundError(publicMessage) { + const error = new Error(publicMessage); + error.statusCode = 404; + error.publicMessage = publicMessage; + throw error; +} + +function optionalNumber(value) { + if (value === undefined || value === null || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeDate(value) { + if (!value) return new Date().toISOString(); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString(); +} + +function readHistory({ driverId, limit }) { + if (!fs.existsSync(HISTORY_FILE)) return []; + + const lines = fs.readFileSync(HISTORY_FILE, "utf8").trim().split("\n").filter(Boolean); + const records = []; + + for (let index = lines.length - 1; index >= 0 && records.length < limit; index -= 1) { + try { + const record = JSON.parse(lines[index]); + if (!driverId || record.driverId === driverId) { + records.push(record); + } + } catch { + continue; + } + } + + return records.reverse(); +} + +function openEventStream(req, res) { + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }); + + res.write(`event: snapshot\n`); + res.write(`data: ${JSON.stringify({ locations: Object.values(latestLocations) })}\n\n`); + sseClients.add(res); + + const heartbeat = setInterval(() => { + res.write(`event: heartbeat\n`); + res.write(`data: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`); + }, 15000); + + req.on("close", () => { + clearInterval(heartbeat); + sseClients.delete(res); + }); +} + +function broadcastEvent(type, data) { + const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + for (const client of sseClients) { + client.write(payload); + } +} + +function compareLatestLocations(left, right) { + return new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime(); +} + +function compareDrivers(left, right) { + return String(left.vehicleNo || left.driverId).localeCompare(String(right.vehicleNo || right.driverId), "ko"); +} + +function toDriverResponse(driver) { + return { + driverId: driver.driverId, + name: driver.name || driver.driverId, + vehicleNo: driver.vehicleNo || "", + phone: driver.phone || "", + token: driver.token || "", + enabled: driver.enabled !== false, + createdAt: driver.createdAt || null, + updatedAt: driver.updatedAt || null + }; +} + +function normalizeDriverId(value) { + const rawValue = String(value || "").trim(); + if (!rawValue) return ""; + + const normalized = rawValue + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + + if (!normalized) validationError("invalid_driver_id"); + return normalized; +} + +function generateDriverId() { + return `driver-${Date.now().toString(36)}`; +} + +function generateDeviceToken() { + return randomUUID().replace(/-/g, ""); +} + +function clampNumber(value, min, max) { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +function serveStaticFile(req, res, pathname) { + const normalizedPath = pathname === "/" ? "/index.html" : pathname; + const decodedPath = decodeURIComponent(normalizedPath); + const filePath = path.resolve(WEB_ROOT, `.${decodedPath}`); + const relativePath = path.relative(WEB_ROOT, filePath); + + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + sendJson(res, 403, { error: "forbidden" }); + return true; + } + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return false; + } + + const contentType = getContentType(filePath); + res.writeHead(200, { "Content-Type": contentType }); + + if (req.method === "HEAD") { + res.end(); + return true; + } + + fs.createReadStream(filePath).pipe(res); + return true; +} + +function getContentType(filePath) { + const extension = path.extname(filePath).toLowerCase(); + const types = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml" + }; + + return types[extension] || "application/octet-stream"; +} diff --git a/apps/web/public/app.js b/apps/web/public/app.js new file mode 100644 index 0000000..546b8ed --- /dev/null +++ b/apps/web/public/app.js @@ -0,0 +1,720 @@ +const state = { + map: null, + markers: new Map(), + infoOverlay: null, + drivers: new Map(), + locations: new Map(), + selectedDriverId: "", + eventSource: null, + activeView: "locations", + adminToken: new URLSearchParams(window.location.search).get("adminToken") || + window.localStorage.getItem("cargoRadarAdminToken") || + "" +}; + +const elements = { + connectionStatus: document.getElementById("connectionStatus"), + refreshButton: document.getElementById("refreshButton"), + demoButton: document.getElementById("demoButton"), + locationsTab: document.getElementById("locationsTab"), + driversTab: document.getElementById("driversTab"), + locationsPanel: document.getElementById("locationsPanel"), + driversPanel: document.getElementById("driversPanel"), + vehicleList: document.getElementById("vehicleList"), + mapMessage: document.getElementById("mapMessage"), + totalCount: document.getElementById("totalCount"), + activeCount: document.getElementById("activeCount"), + staleCount: document.getElementById("staleCount"), + lastUpdated: document.getElementById("lastUpdated"), + driverForm: document.getElementById("driverForm"), + driverNameInput: document.getElementById("driverNameInput"), + vehicleNoInput: document.getElementById("vehicleNoInput"), + phoneInput: document.getElementById("phoneInput"), + driverIdInput: document.getElementById("driverIdInput"), + driverCount: document.getElementById("driverCount"), + driverList: document.getElementById("driverList"), + historyDrawer: document.getElementById("historyDrawer"), + historyCloseButton: document.getElementById("historyCloseButton"), + historyTitle: document.getElementById("historyTitle"), + historySummary: document.getElementById("historySummary"), + historyList: document.getElementById("historyList") +}; + +document.addEventListener("DOMContentLoaded", () => { + initializeMap(); + bindActions(); + refreshDrivers(); + refreshLatestLocations(); + connectEvents(); +}); + +async function initializeMap() { + const kakaoJavaScriptKey = window.CARGORADAR_CONFIG?.kakaoJavaScriptKey || ""; + + if (!kakaoJavaScriptKey) { + showMapMessage("Kakao JavaScript 키가 설정되지 않았습니다."); + return; + } + + try { + await loadKakaoMapsSdk(kakaoJavaScriptKey); + const center = new kakao.maps.LatLng(36.45, 127.85); + state.map = new kakao.maps.Map(document.getElementById("map"), { + center, + level: 13 + }); + + state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT); + state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT); + hideMapMessage(); + updateMarkers({ fitBounds: state.locations.size > 0 }); + } catch (error) { + console.error(error); + showMapMessage("Kakao 지도 키 또는 허용 도메인을 확인하세요."); + } +} + +function loadKakaoMapsSdk(appKey) { + return new Promise((resolve, reject) => { + if (window.kakao?.maps) { + kakao.maps.load(resolve); + return; + } + + const script = document.createElement("script"); + script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${encodeURIComponent(appKey)}&autoload=false`; + script.async = true; + script.onload = () => kakao.maps.load(resolve); + script.onerror = () => reject(new Error("kakao_maps_load_failed")); + document.head.appendChild(script); + }); +} + +function bindActions() { + elements.refreshButton.addEventListener("click", refreshLatestLocations); + elements.demoButton.addEventListener("click", postDemoLocation); + elements.locationsTab.addEventListener("click", () => setActiveView("locations")); + elements.driversTab.addEventListener("click", () => setActiveView("drivers")); + elements.driverForm.addEventListener("submit", submitDriverForm); + elements.historyCloseButton.addEventListener("click", closeHistoryDrawer); +} + +function setActiveView(view) { + state.activeView = view; + elements.locationsTab.classList.toggle("is-active", view === "locations"); + elements.driversTab.classList.toggle("is-active", view === "drivers"); + elements.locationsPanel.classList.toggle("is-hidden", view !== "locations"); + elements.driversPanel.classList.toggle("is-hidden", view !== "drivers"); +} + +async function refreshLatestLocations() { + try { + setConnectionStatus("연결됨", "online"); + const data = await fetchJson("/api/v1/locations/latest", { + headers: adminHeaders() + }); + replaceLocations(data.locations || []); + } catch (error) { + console.error(error); + if (error.message === "invalid_admin_token") { + requestAdminToken(); + return; + } + setConnectionStatus("조회 실패", "error"); + } +} + +async function refreshDrivers() { + try { + const data = await fetchJson("/api/v1/drivers", { + headers: adminHeaders() + }); + replaceDrivers(data.drivers || []); + } catch (error) { + console.error(error); + if (error.message === "invalid_admin_token") { + requestAdminToken(); + } + } +} + +function connectEvents() { + if (state.eventSource) { + state.eventSource.close(); + } + + state.eventSource = new EventSource(withAdminToken("/api/v1/events")); + state.eventSource.addEventListener("open", () => setConnectionStatus("실시간 연결", "online")); + state.eventSource.addEventListener("snapshot", (event) => { + const payload = JSON.parse(event.data); + replaceLocations(payload.locations || []); + }); + state.eventSource.addEventListener("location.updated", (event) => { + upsertLocation(JSON.parse(event.data), { focus: true }); + }); + state.eventSource.addEventListener("drivers.updated", (event) => { + const payload = JSON.parse(event.data); + replaceDrivers(payload.drivers || []); + }); + state.eventSource.addEventListener("error", () => setConnectionStatus("재연결 중", "error")); +} + +async function postDemoLocation() { + const driver = getDemoDriver(); + const now = Date.now(); + const latitude = 37.5665 + Math.sin(now / 60000) * 0.03; + const longitude = 126.978 + Math.cos(now / 60000) * 0.04; + + try { + await fetchJson("/api/v1/locations", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Device-Token": driver.token + }, + body: JSON.stringify({ + driverId: driver.driverId, + vehicleNo: driver.vehicleNo, + latitude, + longitude, + accuracy: 12, + speed: 42, + heading: Math.round((now / 1000) % 360), + provider: "demo", + recordedAt: new Date().toISOString() + }) + }); + } catch (error) { + console.error(error); + setConnectionStatus("전송 실패", "error"); + } +} + +function getDemoDriver() { + const enabledDriver = [...state.drivers.values()].find((driver) => driver.enabled && driver.token); + return enabledDriver || { + driverId: "demo-driver", + vehicleNo: "SEOUL-12-3456", + token: "demo-token" + }; +} + +function replaceLocations(locations) { + state.locations.clear(); + for (const location of locations) { + state.locations.set(location.driverId, location); + } + render(); + updateMarkers({ fitBounds: locations.length > 0 }); +} + +function replaceDrivers(drivers) { + state.drivers.clear(); + for (const driver of drivers) { + state.drivers.set(driver.driverId, driver); + } + renderDrivers(); +} + +function upsertLocation(location, options = {}) { + state.locations.set(location.driverId, location); + render(); + updateMarker(location); + + if (state.selectedDriverId === location.driverId && !elements.historyDrawer.classList.contains("is-hidden")) { + loadLocationHistory(location); + } + + if (options.focus && state.map && window.kakao?.maps) { + state.map.setCenter(new kakao.maps.LatLng(location.latitude, location.longitude)); + } +} + +function render() { + const locations = [...state.locations.values()].sort((left, right) => { + return new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime(); + }); + + const counts = locations.reduce( + (accumulator, location) => { + const status = getLocationStatus(location); + accumulator.total += 1; + if (status.key === "active") accumulator.active += 1; + if (status.key !== "active") accumulator.stale += 1; + return accumulator; + }, + { total: 0, active: 0, stale: 0 } + ); + + elements.totalCount.textContent = String(counts.total); + elements.activeCount.textContent = String(counts.active); + elements.staleCount.textContent = String(counts.stale); + elements.lastUpdated.textContent = locations[0] ? formatDateTime(locations[0].receivedAt) : "-"; + + elements.vehicleList.replaceChildren(); + + if (!locations.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = "수신된 차량 위치가 없습니다."; + elements.vehicleList.append(empty); + return; + } + + for (const location of locations) { + elements.vehicleList.append(createVehicleItem(location)); + } +} + +function renderDrivers() { + const drivers = [...state.drivers.values()].sort((left, right) => { + return String(left.vehicleNo || left.driverId).localeCompare(String(right.vehicleNo || right.driverId), "ko"); + }); + + elements.driverCount.textContent = `${drivers.length}명`; + elements.driverList.replaceChildren(); + + if (!drivers.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = "등록된 기사가 없습니다."; + elements.driverList.append(empty); + return; + } + + for (const driver of drivers) { + elements.driverList.append(createDriverItem(driver)); + } +} + +function createVehicleItem(location) { + const status = getLocationStatus(location); + const item = document.createElement("article"); + item.className = "vehicle-item"; + item.tabIndex = 0; + + const main = document.createElement("div"); + main.className = "vehicle-main"; + + const title = document.createElement("div"); + title.className = "vehicle-title"; + + const badge = document.createElement("span"); + badge.className = `badge ${status.key}`; + badge.textContent = status.label; + + const vehicleNo = document.createElement("strong"); + vehicleNo.textContent = location.vehicleNo || location.driverId; + + title.append(badge, vehicleNo); + + const meta = document.createElement("div"); + meta.className = "vehicle-meta"; + meta.textContent = `${location.driverName || location.driverId} · ${formatDateTime(location.receivedAt)} · ${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`; + + const speed = document.createElement("div"); + speed.className = "speed"; + speed.textContent = location.speed === null || location.speed === undefined ? "-" : `${Math.round(location.speed)} km/h`; + + main.append(title, meta); + item.append(main, speed); + + item.addEventListener("click", () => focusLocation(location)); + item.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + focusLocation(location); + } + }); + + return item; +} + +function createDriverItem(driver) { + const item = document.createElement("article"); + item.className = "driver-item"; + + const head = document.createElement("div"); + head.className = "driver-head"; + + const title = document.createElement("div"); + title.className = "driver-title"; + + const name = document.createElement("strong"); + name.textContent = `${driver.vehicleNo || driver.driverId} · ${driver.name}`; + + const meta = document.createElement("div"); + meta.className = "vehicle-meta"; + meta.textContent = [driver.driverId, driver.phone].filter(Boolean).join(" · "); + + title.append(name, meta); + + const badge = document.createElement("span"); + badge.className = `badge ${driver.enabled ? "active" : "offline"}`; + badge.textContent = driver.enabled ? "사용" : "중지"; + + head.append(title, badge); + + const tokenRow = document.createElement("div"); + tokenRow.className = "driver-token"; + + const token = document.createElement("code"); + token.textContent = driver.token || "-"; + + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.textContent = "복사"; + copyButton.addEventListener("click", () => copyToken(driver.token)); + + tokenRow.append(token, copyButton); + + const actions = document.createElement("div"); + actions.className = "driver-actions"; + + const toggleButton = document.createElement("button"); + toggleButton.type = "button"; + toggleButton.textContent = driver.enabled ? "중지" : "사용"; + toggleButton.addEventListener("click", () => updateDriver(driver.driverId, { enabled: !driver.enabled })); + + const tokenButton = document.createElement("button"); + tokenButton.type = "button"; + tokenButton.textContent = "토큰 재발급"; + tokenButton.addEventListener("click", () => updateDriver(driver.driverId, { regenerateToken: true })); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.textContent = "삭제"; + deleteButton.addEventListener("click", () => deleteDriver(driver)); + + actions.append(toggleButton, tokenButton, deleteButton); + item.append(head, tokenRow, actions); + return item; +} + +async function submitDriverForm(event) { + event.preventDefault(); + + const payload = { + name: elements.driverNameInput.value.trim(), + vehicleNo: elements.vehicleNoInput.value.trim(), + phone: elements.phoneInput.value.trim(), + driverId: elements.driverIdInput.value.trim() + }; + + try { + await fetchJson("/api/v1/drivers", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...adminHeaders() + }, + body: JSON.stringify(payload) + }); + elements.driverForm.reset(); + await refreshDrivers(); + } catch (error) { + console.error(error); + window.alert(`등록 실패: ${error.message}`); + } +} + +async function updateDriver(driverId, patch) { + try { + const data = await fetchJson(`/api/v1/drivers/${encodeURIComponent(driverId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...adminHeaders() + }, + body: JSON.stringify(patch) + }); + state.drivers.set(data.driver.driverId, data.driver); + renderDrivers(); + } catch (error) { + console.error(error); + window.alert(`수정 실패: ${error.message}`); + } +} + +async function deleteDriver(driver) { + const confirmed = window.confirm(`${driver.vehicleNo || driver.driverId} 삭제`); + if (!confirmed) return; + + try { + await fetchJson(`/api/v1/drivers/${encodeURIComponent(driver.driverId)}`, { + method: "DELETE", + headers: adminHeaders() + }); + state.drivers.delete(driver.driverId); + renderDrivers(); + } catch (error) { + console.error(error); + window.alert(`삭제 실패: ${error.message}`); + } +} + +async function copyToken(token) { + if (!token) return; + + try { + await navigator.clipboard.writeText(token); + setConnectionStatus("토큰 복사됨", "online"); + } catch { + window.prompt("기기 토큰", token); + } +} + +function updateMarkers({ fitBounds = false } = {}) { + if (!state.map || !window.kakao?.maps) return; + + const activeIds = new Set(state.locations.keys()); + + for (const [driverId, markerOverlay] of state.markers) { + if (!activeIds.has(driverId)) { + markerOverlay.setMap(null); + state.markers.delete(driverId); + } + } + + for (const location of state.locations.values()) { + updateMarker(location); + } + + if (fitBounds && state.markers.size > 0) { + const bounds = new kakao.maps.LatLngBounds(); + for (const location of state.locations.values()) { + bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude)); + } + state.map.setBounds(bounds); + } +} + +function updateMarker(location) { + if (!state.map || !window.kakao?.maps) return; + + const position = new kakao.maps.LatLng(location.latitude, location.longitude); + const status = getLocationStatus(location); + const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4); + + const markerElement = document.createElement("button"); + markerElement.className = `kakao-marker ${status.key}`; + markerElement.type = "button"; + markerElement.textContent = label; + markerElement.title = location.vehicleNo || location.driverId; + markerElement.addEventListener("click", () => focusLocation(location)); + + let markerOverlay = state.markers.get(location.driverId); + + if (!markerOverlay) { + markerOverlay = new kakao.maps.CustomOverlay({ + position, + content: markerElement, + yAnchor: 1 + }); + markerOverlay.setMap(state.map); + state.markers.set(location.driverId, markerOverlay); + } else { + markerOverlay.setPosition(position); + markerOverlay.setContent(markerElement); + } +} + +function focusLocation(location) { + loadLocationHistory(location); + + if (!state.map || !window.kakao?.maps) return; + + const position = new kakao.maps.LatLng(location.latitude, location.longitude); + state.map.setCenter(position); + if (state.map.getLevel() > 4) { + state.map.setLevel(4); + } + showInfoOverlay(location); +} + +async function loadLocationHistory(location) { + state.selectedDriverId = location.driverId; + elements.historyDrawer.classList.remove("is-hidden"); + elements.historyTitle.textContent = `${location.vehicleNo || location.driverId} 위치 이력`; + elements.historySummary.textContent = "조회 중"; + elements.historyList.replaceChildren(); + + try { + const data = await fetchJson( + `/api/v1/locations/history?driverId=${encodeURIComponent(location.driverId)}&limit=80`, + { headers: adminHeaders() } + ); + renderHistory(location, data.locations || []); + } catch (error) { + console.error(error); + elements.historySummary.textContent = `조회 실패: ${error.message}`; + } +} + +function renderHistory(location, records) { + elements.historyList.replaceChildren(); + elements.historySummary.textContent = `${records.length}건 · ${location.driverName || location.driverId}`; + + if (!records.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = "위치 이력이 없습니다."; + elements.historyList.append(empty); + return; + } + + for (const record of records.reverse()) { + const row = document.createElement("div"); + row.className = "history-row"; + + const time = document.createElement("time"); + time.textContent = formatTime(record.receivedAt); + + const coordinate = document.createElement("span"); + coordinate.textContent = `${formatCoordinate(record.latitude)}, ${formatCoordinate(record.longitude)}`; + + const speed = document.createElement("span"); + speed.textContent = record.speed === null || record.speed === undefined ? "-" : `${Math.round(record.speed)} km/h`; + + row.append(time, coordinate, speed); + elements.historyList.append(row); + } +} + +function closeHistoryDrawer() { + state.selectedDriverId = ""; + elements.historyDrawer.classList.add("is-hidden"); +} + +function showInfoOverlay(location) { + const status = getLocationStatus(location); + const position = new kakao.maps.LatLng(location.latitude, location.longitude); + const content = createPopupElement(location, status); + + if (!state.infoOverlay) { + state.infoOverlay = new kakao.maps.CustomOverlay({ + yAnchor: 1.25 + }); + } + + state.infoOverlay.setContent(content); + state.infoOverlay.setPosition(position); + state.infoOverlay.setMap(state.map); +} + +function createPopupElement(location, status) { + const container = document.createElement("div"); + container.className = "kakao-popup"; + + const title = document.createElement("strong"); + title.className = "popup-title"; + title.textContent = location.vehicleNo || location.driverId; + + const driver = document.createElement("div"); + driver.textContent = `${location.driverName || location.driverId} · ${status.label}`; + + const receivedAt = document.createElement("div"); + receivedAt.textContent = formatDateTime(location.receivedAt); + + const coordinate = document.createElement("div"); + coordinate.textContent = `${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`; + + container.append(title, driver, receivedAt, coordinate); + return container; +} + +async function fetchJson(url, options) { + const response = await fetch(url, options); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +function adminHeaders() { + return state.adminToken ? { "X-Admin-Token": state.adminToken } : {}; +} + +function withAdminToken(url) { + if (!state.adminToken) return url; + + const parsedUrl = new URL(url, window.location.origin); + parsedUrl.searchParams.set("adminToken", state.adminToken); + return `${parsedUrl.pathname}${parsedUrl.search}`; +} + +function requestAdminToken() { + const token = window.prompt("관리자 토큰"); + if (!token) { + setConnectionStatus("토큰 필요", "error"); + return; + } + + state.adminToken = token.trim(); + window.localStorage.setItem("cargoRadarAdminToken", state.adminToken); + refreshDrivers(); + refreshLatestLocations(); + connectEvents(); +} + +function setConnectionStatus(text, mode) { + elements.connectionStatus.textContent = text; + elements.connectionStatus.classList.toggle("is-online", mode === "online"); + elements.connectionStatus.classList.toggle("is-error", mode === "error"); +} + +function showMapMessage(text) { + elements.mapMessage.textContent = text; + elements.mapMessage.classList.remove("is-hidden"); +} + +function hideMapMessage() { + elements.mapMessage.classList.add("is-hidden"); +} + +function getLocationStatus(location) { + const receivedAt = new Date(location.receivedAt).getTime(); + const ageMs = Date.now() - receivedAt; + + if (ageMs <= 2 * 60 * 1000) return { key: "active", label: "수신중" }; + if (ageMs <= 10 * 60 * 1000) return { key: "stale", label: "지연" }; + return { key: "offline", label: "미수신" }; +} + +function formatDateTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }).format(date); +} + +function formatTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return new Intl.DateTimeFormat("ko-KR", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }).format(date); +} + +function formatCoordinate(value) { + return Number(value).toFixed(5); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/web/public/index.html b/apps/web/public/index.html new file mode 100644 index 0000000..fc1d096 --- /dev/null +++ b/apps/web/public/index.html @@ -0,0 +1,103 @@ + + + + + + CargoRadar Control + + + +
+
+
+ CargoRadar + Control +
+
+ 연결 대기 + + +
+
+ +
+ + +
+
+
지도 로딩 중
+ +
+
+
+ + + + + diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css new file mode 100644 index 0000000..45bc0c3 --- /dev/null +++ b/apps/web/public/styles.css @@ -0,0 +1,594 @@ +:root { + color-scheme: light; + --bg: #f5f7f9; + --surface: #ffffff; + --surface-muted: #eef2f5; + --border: #d9e0e7; + --text: #17212b; + --muted: #657487; + --blue: #1f6feb; + --green: #15803d; + --amber: #b45309; + --red: #b42318; + --shadow: 0 12px 30px rgba(25, 37, 52, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: + "Segoe UI", + "Noto Sans KR", + system-ui, + -apple-system, + BlinkMacSystemFont, + sans-serif; + letter-spacing: 0; +} + +button { + min-height: 36px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +button:hover { + border-color: #9aa9b8; + background: #f9fbfc; +} + +.app-shell { + display: grid; + grid-template-rows: 58px minmax(0, 1fr); + width: 100%; + height: 100%; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 18px; + border-bottom: 1px solid var(--border); + background: var(--surface); + box-shadow: 0 1px 0 rgba(23, 33, 43, 0.02); +} + +.brand { + font-size: 18px; +} + +.subtitle { + margin-left: 8px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-muted); + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.status-pill.is-online { + border-color: rgba(21, 128, 61, 0.25); + background: rgba(21, 128, 61, 0.08); + color: var(--green); +} + +.status-pill.is-error { + border-color: rgba(180, 35, 24, 0.25); + background: rgba(180, 35, 24, 0.08); + color: var(--red); +} + +.workspace { + display: grid; + grid-template-columns: minmax(320px, 380px) minmax(0, 1fr); + min-height: 0; +} + +.sidebar { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; + min-height: 0; + padding: 14px; + border-right: 1px solid var(--border); + background: #fbfcfd; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.metric { + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.metric-label { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.metric strong { + display: block; + margin-top: 4px; + font-size: 24px; + line-height: 1.1; +} + +.view-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-muted); +} + +.tab-button { + min-width: 0; + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.tab-button.is-active { + border-color: var(--border); + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 4px rgba(25, 37, 52, 0.08); +} + +.vehicle-panel { + min-height: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); + overflow: hidden; +} + +.vehicle-panel.is-hidden { + display: none; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-bottom: 1px solid var(--border); +} + +.panel-heading h1 { + margin: 0; + font-size: 16px; + line-height: 1.2; +} + +.panel-heading span { + color: var(--muted); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.vehicle-list { + height: calc(100% - 50px); + overflow: auto; +} + +.driver-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 12px; + border-bottom: 1px solid var(--border); +} + +.driver-form label { + display: grid; + gap: 5px; + min-width: 0; +} + +.driver-form label:nth-child(3), +.driver-form label:nth-child(4), +.driver-form button { + grid-column: 1 / -1; +} + +.driver-form span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.driver-form input { + width: 100%; + min-height: 36px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0 9px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.driver-form input:focus { + border-color: var(--blue); + outline: 2px solid rgba(31, 111, 235, 0.14); +} + +.driver-list { + height: calc(100% - 259px); + min-height: 160px; + overflow: auto; +} + +.driver-item { + display: grid; + gap: 10px; + padding: 13px 14px; + border-bottom: 1px solid var(--border); +} + +.driver-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} + +.driver-title { + min-width: 0; +} + +.driver-title strong, +.driver-token code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.driver-token { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafb; +} + +.driver-token code { + color: #25313d; + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 12px; +} + +.driver-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.empty-state { + padding: 20px 14px; + color: var(--muted); + font-size: 14px; + line-height: 1.5; +} + +.vehicle-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 13px 14px; + border-bottom: 1px solid var(--border); + cursor: pointer; +} + +.vehicle-item:hover { + background: #f6f9fb; +} + +.vehicle-main { + min-width: 0; +} + +.vehicle-title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.vehicle-title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vehicle-meta { + margin-top: 6px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.badge.active { + background: rgba(21, 128, 61, 0.1); + color: var(--green); +} + +.badge.stale { + background: rgba(180, 83, 9, 0.12); + color: var(--amber); +} + +.badge.offline { + background: rgba(180, 35, 24, 0.1); + color: var(--red); +} + +.speed { + color: var(--text); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.map-panel { + position: relative; + min-width: 0; + min-height: 0; +} + +#map { + width: 100%; + height: 100%; + background: #dfe7ed; +} + +.map-message { + position: absolute; + z-index: 5; + top: 14px; + left: 50%; + max-width: min(420px, calc(100% - 28px)); + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + color: var(--muted); + box-shadow: var(--shadow); + font-size: 13px; + font-weight: 800; + line-height: 1.35; + text-align: center; + transform: translateX(-50%); +} + +.map-message.is-hidden { + display: none; +} + +.history-drawer { + position: absolute; + z-index: 4; + right: 14px; + bottom: 14px; + left: 14px; + max-height: min(310px, calc(100% - 28px)); + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.97); + box-shadow: var(--shadow); + overflow: hidden; +} + +.history-drawer.is-hidden { + display: none; +} + +.history-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--border); +} + +.history-heading strong, +.history-heading span { + display: block; +} + +.history-heading span { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.history-list { + display: grid; + max-height: 248px; + overflow: auto; +} + +.history-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) 72px; + gap: 10px; + align-items: center; + padding: 9px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.history-row time, +.history-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-row time { + color: var(--muted); + font-weight: 800; +} + +.kakao-marker { + display: flex; + align-items: center; + justify-content: center; + width: max-content; + min-width: 42px; + min-height: 30px; + padding: 0 9px; + border: 2px solid #ffffff; + border-radius: 999px; + background: var(--blue); + color: #ffffff; + box-shadow: 0 8px 18px rgba(16, 32, 48, 0.25); + font-size: 11px; + font-weight: 900; + font-family: inherit; + cursor: pointer; +} + +.kakao-marker.stale { + background: var(--amber); +} + +.kakao-marker.offline { + background: var(--red); +} + +.kakao-popup { + min-width: 180px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); + color: var(--text); + font-size: 13px; + line-height: 1.45; +} + +.popup-title { + display: block; + margin-bottom: 6px; + font-weight: 900; +} + +@media (max-width: 860px) { + .app-shell { + grid-template-rows: auto minmax(0, 1fr); + } + + .topbar { + align-items: flex-start; + flex-direction: column; + padding: 12px; + } + + .toolbar { + width: 100%; + flex-wrap: wrap; + } + + .workspace { + grid-template-columns: 1fr; + grid-template-rows: minmax(260px, 38vh) minmax(0, 1fr); + } + + .sidebar { + order: 2; + border-top: 1px solid var(--border); + border-right: 0; + } + + .map-panel { + order: 1; + } + + .driver-actions { + grid-template-columns: 1fr; + } + + .history-drawer { + right: 8px; + bottom: 8px; + left: 8px; + } + + .history-row { + grid-template-columns: 82px minmax(0, 1fr); + } + + .history-row span:last-child { + display: none; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d4c971 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + cargoradar: + build: . + container_name: cargoradar + restart: unless-stopped + ports: + - "${CARGORADAR_PORT:-8080}:8080" + environment: + HOST: 0.0.0.0 + PORT: 8080 + DATA_DIR: /data + WEB_ROOT: /app/apps/web/public + ADMIN_TOKEN: ${ADMIN_TOKEN:-} + KAKAO_JAVASCRIPT_KEY: ${KAKAO_JAVASCRIPT_KEY:-} + volumes: + - ./runtime-data:/data + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..dba13c0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,29 @@ +# Architecture + +```mermaid +flowchart LR + phone["Android driver app"] -->|POST /api/v1/locations| api["CargoRadar API"] + api --> latest["latest-locations.json"] + api --> history["locations.jsonl"] + web["Control web"] -->|GET latest/history| api + api -->|SSE /api/v1/events| web +``` + +## MVP Data Flow + +1. The Android app starts a foreground location service. +2. The service uploads location JSON with `X-Device-Token`. +3. The server validates the token against `drivers.json`. +4. The server stores the newest location per driver and appends history. +5. The control web reads the latest positions and listens for SSE updates. + +## Storage + +The MVP uses simple files: + +- `drivers.json`: driver registry and device tokens. +- `latest-locations.json`: current position by driver. +- `locations.jsonl`: append-only location history. + +This keeps the first version easy to deploy on Synology NAS. PostgreSQL with +PostGIS is the planned upgrade path once route/history queries become central. diff --git a/docs/SYNOLOGY.md b/docs/SYNOLOGY.md new file mode 100644 index 0000000..488df92 --- /dev/null +++ b/docs/SYNOLOGY.md @@ -0,0 +1,108 @@ +# Synology Deployment + +## Container Manager + +1. Copy this repository or the NAS deploy ZIP to the NAS. +2. Extract it into a persistent folder, for example + `/docker/cargoradar`. +3. Create `.env` from `.env.example`. +4. Set `CARGORADAR_PORT` and, if needed, `ADMIN_TOKEN`. +5. Create a Container Manager project from `docker-compose.yml`. +6. Keep `runtime-data` mapped to persistent NAS storage. + +## Environment + +```text +CARGORADAR_PORT=8080 +ADMIN_TOKEN= +KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5 +``` + +For NAS deployment, start from `.env.nas.example` instead. The recommended NAS +host port is `18081` because `18080` is already forwarded for `opendaw`. + +```text +CARGORADAR_PORT=18081 +ADMIN_TOKEN=change_this_admin_token +KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5 +``` + +`ADMIN_TOKEN` can stay blank during local MVP testing. For NAS exposure, set it. +If opening the app directly by port, use: + +```text +http://:18081/?adminToken= +``` + +The better public route is Synology Reverse Proxy: + +```text +https://cargoradar.comtropy.synology.me -> http://127.0.0.1:18081 +``` + +## Kakao Domain Entries + +Register every origin that opens the control screen: + +```text +http://localhost:8080 +http://127.0.0.1:8080 +http://:18081 +https://cargoradar.comtropy.synology.me +https:// +``` + +## Android URL + +On the same LAN, use: + +```text +http://:18081 +``` + +When reverse proxy and HTTPS are ready, use: + +```text +https://cargoradar.comtropy.synology.me +``` + +## Smoke Test + +After deployment, run from a PC that can reach the NAS: + +```powershell +.\scripts\health-check.ps1 -BaseUrl "http://:18081" -AdminToken "" +.\scripts\post-demo-location.ps1 -BaseUrl "http://:18081" +``` + +Then open the control screen and confirm that the demo location appears in the +`위치` tab. + +## Local Package + +Create a NAS-ready ZIP from the repository root: + +```powershell +.\scripts\package-nas-deploy.ps1 +``` + +Upload the resulting `dist/cargoradar-nas-*.zip` to the NAS and extract it into +the deployment folder. + +## Current Router Context + +Known forwarding entries: + +```text +50001 -> 192.168.200.129:5000 DSM HTTP +50002 -> 192.168.200.129:5001 DSM HTTPS +80 -> 192.168.200.129:80 Web Station / reverse proxy +443 -> 192.168.200.129:443 HTTPS reverse proxy +3000 -> 192.168.200.129:3000 Gitea +2222 -> 192.168.200.129:2222 Gitea SSH +50022 -> 192.168.200.129:22 SSH +18080 -> 192.168.200.129:18080 opendaw +``` + +Do not use `18080` for CargoRadar. Prefer a NAS-only host port such as `18081` +and publish it through reverse proxy on `443`. diff --git a/scripts/backup-runtime-data.ps1 b/scripts/backup-runtime-data.ps1 new file mode 100644 index 0000000..98d0a6c --- /dev/null +++ b/scripts/backup-runtime-data.ps1 @@ -0,0 +1,24 @@ +param( + [string]$DataDir = "runtime-data", + [string]$BackupDir = "backups" +) + +$ErrorActionPreference = "Stop" + +$root = (Resolve-Path ".").Path +$resolvedDataDir = Resolve-Path $DataDir + +if (-not $resolvedDataDir.Path.StartsWith($root)) { + throw "Refusing to back up a path outside the repository: $($resolvedDataDir.Path)" +} + +New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$archivePath = Join-Path $BackupDir "cargoradar-runtime-$timestamp.zip" + +Compress-Archive -Path (Join-Path $resolvedDataDir.Path "*") -DestinationPath $archivePath -Force + +[pscustomobject]@{ + Backup = (Resolve-Path $archivePath).Path +} | Format-List diff --git a/scripts/health-check.ps1 b/scripts/health-check.ps1 new file mode 100644 index 0000000..b502ce0 --- /dev/null +++ b/scripts/health-check.ps1 @@ -0,0 +1,32 @@ +param( + [string]$BaseUrl = "http://127.0.0.1:8080", + [string]$AdminToken = "" +) + +$ErrorActionPreference = "Stop" + +function Invoke-CargoRadarJson { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $headers = @{} + if ($AdminToken) { + $headers["X-Admin-Token"] = $AdminToken + } + + Invoke-RestMethod -Uri "$BaseUrl$Path" -Method Get -Headers $headers +} + +$health = Invoke-CargoRadarJson -Path "/health" +$drivers = Invoke-CargoRadarJson -Path "/api/v1/drivers" +$latest = Invoke-CargoRadarJson -Path "/api/v1/locations/latest" + +[pscustomobject]@{ + BaseUrl = $BaseUrl + Health = $health.status + ServerTime = $health.time + DriverCount = $drivers.drivers.Count + LatestLocationCount = $latest.locations.Count +} | Format-List diff --git a/scripts/package-nas-deploy.ps1 b/scripts/package-nas-deploy.ps1 new file mode 100644 index 0000000..9a59a5c --- /dev/null +++ b/scripts/package-nas-deploy.ps1 @@ -0,0 +1,76 @@ +param( + [string]$OutputDir = "dist" +) + +$ErrorActionPreference = "Stop" + +$root = (Resolve-Path ".").Path +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$archivePath = Join-Path $OutputDir "cargoradar-nas-$timestamp.zip" +$staging = Join-Path $OutputDir "cargoradar-nas-stage" + +if (Test-Path $staging) { + $resolvedStaging = (Resolve-Path $staging).Path + if (-not $resolvedStaging.StartsWith($root)) { + throw "Refusing to remove outside the repository: $resolvedStaging" + } + Remove-Item -LiteralPath $resolvedStaging -Recurse -Force +} + +New-Item -ItemType Directory -Path $staging | Out-Null +$stagingPath = (Resolve-Path $staging).Path + +$items = @( + ".dockerignore", + ".env.example", + ".env.nas.example", + "Dockerfile", + "docker-compose.yml", + "README.md", + "apps", + "docs", + "scripts" +) + +foreach ($item in $items) { + $source = Join-Path $root $item + if (-not (Test-Path $source)) { + throw "Missing deploy item: $item" + } + + Copy-Item -Path $source -Destination $stagingPath -Recurse -Force +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +if (Test-Path $archivePath) { + Remove-Item -LiteralPath $archivePath -Force +} + +$zip = [System.IO.Compression.ZipFile]::Open($archivePath, [System.IO.Compression.ZipArchiveMode]::Create) +try { + $files = Get-ChildItem -Path $stagingPath -Recurse -File -Force + foreach ($file in $files) { + $relativePath = $file.FullName.Substring($stagingPath.Length) + $relativePath = $relativePath -replace "^[\\/]+", "" + $entryName = $relativePath -replace "\\", "/" + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, + $file.FullName, + $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } +} +finally { + $zip.Dispose() +} + +Remove-Item -LiteralPath $stagingPath -Recurse -Force + +[pscustomobject]@{ + Archive = (Resolve-Path $archivePath).Path +} | Format-List diff --git a/scripts/post-demo-location.ps1 b/scripts/post-demo-location.ps1 new file mode 100644 index 0000000..cd54fde --- /dev/null +++ b/scripts/post-demo-location.ps1 @@ -0,0 +1,29 @@ +param( + [string]$BaseUrl = "http://127.0.0.1:8080", + [string]$DeviceToken = "demo-token", + [string]$DriverId = "demo-driver", + [string]$VehicleNo = "SEOUL-12-3456", + [double]$Latitude = 37.5665, + [double]$Longitude = 126.9780 +) + +$ErrorActionPreference = "Stop" + +$body = @{ + driverId = $DriverId + vehicleNo = $VehicleNo + latitude = $Latitude + longitude = $Longitude + accuracy = 12 + speed = 42 + heading = 90 + provider = "script" + recordedAt = (Get-Date).ToUniversalTime().ToString("o") +} | ConvertTo-Json + +Invoke-RestMethod ` + -Uri "$BaseUrl/api/v1/locations" ` + -Method Post ` + -Headers @{ "X-Device-Token" = $DeviceToken } ` + -ContentType "application/json" ` + -Body $body