초기 관제 시스템 뼈대 구성
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
CARGORADAR_PORT=8080
|
||||
ADMIN_TOKEN=
|
||||
KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5
|
||||
3
.env.nas.example
Normal file
3
.env.nas.example
Normal file
@@ -0,0 +1,3 @@
|
||||
CARGORADAR_PORT=18081
|
||||
ADMIN_TOKEN=change_this_admin_token
|
||||
KAKAO_JAVASCRIPT_KEY=ea26ed8ef3cf2960e9c36f1c161dadb5
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -10,6 +10,13 @@
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# CargoRadar runtime files
|
||||
.env
|
||||
dist/
|
||||
runtime-data/
|
||||
apps/server/data/*
|
||||
!apps/server/data/.gitkeep
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
@@ -360,4 +367,4 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
194
README.md
Normal 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
29
apps/android/README.md
Normal 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.
|
||||
21
apps/android/app/build.gradle.kts
Normal file
21
apps/android/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
33
apps/android/app/src/main/AndroidManifest.xml
Normal file
33
apps/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
10
apps/android/app/src/main/res/drawable/ic_stat_location.xml
Normal file
10
apps/android/app/src/main/res/drawable/ic_stat_location.xml
Normal 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>
|
||||
6
apps/android/app/src/main/res/values/strings.xml
Normal file
6
apps/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
apps/android/app/src/main/res/values/styles.xml
Normal file
9
apps/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
4
apps/android/build.gradle.kts
Normal file
4
apps/android/build.gradle.kts
Normal 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
|
||||
}
|
||||
18
apps/android/settings.gradle.kts
Normal file
18
apps/android/settings.gradle.kts
Normal 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")
|
||||
1
apps/server/data/.gitkeep
Normal file
1
apps/server/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
apps/server/package.json
Normal file
14
apps/server/package.json
Normal 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
573
apps/server/src/server.js
Normal 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
720
apps/web/public/app.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
103
apps/web/public/index.html
Normal file
103
apps/web/public/index.html
Normal 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
594
apps/web/public/styles.css
Normal 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
26
docker-compose.yml
Normal 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
29
docs/ARCHITECTURE.md
Normal 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
108
docs/SYNOLOGY.md
Normal 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`.
|
||||
24
scripts/backup-runtime-data.ps1
Normal file
24
scripts/backup-runtime-data.ps1
Normal 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
32
scripts/health-check.ps1
Normal 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
|
||||
76
scripts/package-nas-deploy.ps1
Normal file
76
scripts/package-nas-deploy.ps1
Normal 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
|
||||
29
scripts/post-demo-location.ps1
Normal file
29
scripts/post-demo-location.ps1
Normal 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
|
||||
Reference in New Issue
Block a user