초기 관제 시스템 뼈대 구성

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

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