초기 관제 시스템 뼈대 구성

This commit is contained in:
2026-06-06 14:58:54 +09:00
parent d982777718
commit cab7394b1f
29 changed files with 3221 additions and 1 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.vs
backups
tmp
runtime-data
apps/server/data/*
!apps/server/data/.gitkeep
node_modules
*.log
*.zip

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
CARGORADAR_PORT=8080
ADMIN_TOKEN=
KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5

3
.env.nas.example Normal file
View File

@@ -0,0 +1,3 @@
CARGORADAR_PORT=18081
ADMIN_TOKEN=change_this_admin_token
KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5

7
.gitignore vendored
View File

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

20
Dockerfile Normal file
View File

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

194
README.md Normal file
View File

@@ -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=<override_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://<NAS-LAN-IP>:8080
https://<NAS-domain>
```
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://<NAS-or-PC-LAN-IP>: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.

29
apps/android/README.md Normal file
View File

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

View File

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

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".LocationUploadService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C8.14,2 5,5.14 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.86 -3.14,-7 -7,-7zM12,12.5c-1.38,0 -2.5,-1.12 -2.5,-2.5S10.62,7.5 12,7.5s2.5,1.12 2.5,2.5S13.38,12.5 12,12.5z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CargoRadar Driver</string>
<string name="notification_title">CargoRadar 운행 추적</string>
<string name="notification_channel_name">운행 위치 업로드</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:navigationBarColor">#FFFFFF</item>
</style>
</resources>

View File

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

View File

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

View File

@@ -0,0 +1 @@

14
apps/server/package.json Normal file
View File

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

573
apps/server/src/server.js Normal file
View File

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

720
apps/web/public/app.js Normal file
View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}

103
apps/web/public/index.html Normal file
View File

@@ -0,0 +1,103 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CargoRadar Control</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div>
<strong class="brand">CargoRadar</strong>
<span class="subtitle">Control</span>
</div>
<div class="toolbar">
<span id="connectionStatus" class="status-pill">연결 대기</span>
<button id="refreshButton" type="button">새로고침</button>
<button id="demoButton" type="button">샘플 위치</button>
</div>
</header>
<main class="workspace">
<aside class="sidebar" aria-label="차량 목록">
<section class="summary-grid" aria-label="운영 현황">
<div class="metric">
<span class="metric-label">전체</span>
<strong id="totalCount">0</strong>
</div>
<div class="metric">
<span class="metric-label">수신중</span>
<strong id="activeCount">0</strong>
</div>
<div class="metric">
<span class="metric-label">지연</span>
<strong id="staleCount">0</strong>
</div>
</section>
<nav class="view-tabs" aria-label="관제 보기">
<button id="locationsTab" class="tab-button is-active" type="button">위치</button>
<button id="driversTab" class="tab-button" type="button">기사</button>
</nav>
<section id="locationsPanel" class="vehicle-panel">
<div class="panel-heading">
<h1>차량 위치</h1>
<span id="lastUpdated">-</span>
</div>
<div id="vehicleList" class="vehicle-list"></div>
</section>
<section id="driversPanel" class="vehicle-panel is-hidden">
<div class="panel-heading">
<h1>기사 관리</h1>
<span id="driverCount">0명</span>
</div>
<form id="driverForm" class="driver-form">
<label>
<span>기사명</span>
<input id="driverNameInput" name="name" required autocomplete="off" />
</label>
<label>
<span>차량번호</span>
<input id="vehicleNoInput" name="vehicleNo" required autocomplete="off" />
</label>
<label>
<span>전화번호</span>
<input id="phoneInput" name="phone" autocomplete="off" />
</label>
<label>
<span>기사 ID</span>
<input id="driverIdInput" name="driverId" autocomplete="off" />
</label>
<button type="submit">기사 등록</button>
</form>
<div id="driverList" class="driver-list"></div>
</section>
</aside>
<section class="map-panel" aria-label="지도">
<div id="map"></div>
<div id="mapMessage" class="map-message">지도 로딩 중</div>
<section id="historyDrawer" class="history-drawer is-hidden" aria-label="위치 이력">
<div class="history-heading">
<div>
<strong id="historyTitle">위치 이력</strong>
<span id="historySummary">-</span>
</div>
<button id="historyCloseButton" type="button">닫기</button>
</div>
<div id="historyList" class="history-list"></div>
</section>
</section>
</main>
</div>
<script src="/config.js"></script>
<script src="/app.js"></script>
</body>
</html>

594
apps/web/public/styles.css Normal file
View File

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

26
docker-compose.yml Normal file
View File

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

29
docs/ARCHITECTURE.md Normal file
View File

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

108
docs/SYNOLOGY.md Normal file
View File

@@ -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://<NAS-LAN-IP>:18081/?adminToken=<ADMIN_TOKEN>
```
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://<NAS-LAN-IP>:18081
https://cargoradar.comtropy.synology.me
https://<NAS-domain>
```
## Android URL
On the same LAN, use:
```text
http://<NAS-LAN-IP>: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://<NAS-LAN-IP>:18081" -AdminToken "<ADMIN_TOKEN>"
.\scripts\post-demo-location.ps1 -BaseUrl "http://<NAS-LAN-IP>: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`.

View File

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

32
scripts/health-check.ps1 Normal file
View File

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

View File

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

View File

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