@@ -12,6 +12,10 @@ import app.cash.backfila.ui.components.PageTitle
12
12
import app.cash.backfila.ui.components.PaginationWithHistory
13
13
import app.cash.backfila.ui.components.ProgressBar
14
14
import app.cash.backfila.ui.pages.BackfillCreateAction.BackfillCreateField.CUSTOM_PARAMETER_PREFIX
15
+ import java.net.URLDecoder
16
+ import java.time.Instant
17
+ import java.time.ZoneId
18
+ import java.time.format.DateTimeFormatter
15
19
import javax.inject.Inject
16
20
import javax.inject.Singleton
17
21
import kotlinx.html.ButtonType
@@ -36,10 +40,12 @@ import kotlinx.html.td
36
40
import kotlinx.html.th
37
41
import kotlinx.html.thead
38
42
import kotlinx.html.tr
43
+ import misk.scope.ActionScoped
39
44
import misk.security.authz.Authenticated
40
45
import misk.tailwind.Link
41
46
import misk.turbo.turbo_frame
42
47
import misk.web.Get
48
+ import misk.web.HttpCall
43
49
import misk.web.PathParam
44
50
import misk.web.QueryParam
45
51
import misk.web.Response
@@ -54,6 +60,7 @@ class BackfillShowAction @Inject constructor(
54
60
private val dashboardPageLayout : DashboardPageLayout ,
55
61
private val viewLogsAction : ViewLogsAction ,
56
62
private val backfillShowButtonHandlerAction : BackfillShowButtonHandlerAction ,
63
+ private val httpCall : ActionScoped <HttpCall >,
57
64
) : WebAction {
58
65
@Get(PATH )
59
66
@ResponseContentType(MediaTypes .TEXT_HTML )
@@ -68,6 +75,9 @@ class BackfillShowAction @Inject constructor(
68
75
val label =
69
76
if (backfill.variant == " default" ) backfill.service_name else " ${backfill.service_name} (${backfill.variant} )"
70
77
78
+ // Get user's timezone from cookie
79
+ val userTimezone = getUserTimezone()
80
+
71
81
val configurationRows = backfill.toConfigurationRows(id)
72
82
val leftColumnConfigurationRows = configurationRows.take(
73
83
configurationRows.size / 2 +
@@ -79,9 +89,8 @@ class BackfillShowAction @Inject constructor(
79
89
val htmlResponseBody = dashboardPageLayout.newBuilder()
80
90
.title(" Backfill $id | Backfila" )
81
91
.headBlock {
82
- // Add JavaScript to format timestamps in user's timezone
83
92
script {
84
- + formatToLocalTimestampsScript ()
93
+ + timezoneDetectionScript ()
85
94
}
86
95
}
87
96
.breadcrumbLinks(
@@ -140,7 +149,7 @@ class BackfillShowAction @Inject constructor(
140
149
h2(" text-base font-semibold leading-6 text-gray-900" ) { + """ Configuration""" }
141
150
dl(" divide-y divide-gray-100" ) {
142
151
leftColumnConfigurationRows.map {
143
- ConfigurationRows (id, it, backfill)
152
+ ConfigurationRows (id, it, backfill, userTimezone )
144
153
}
145
154
}
146
155
}
@@ -149,7 +158,7 @@ class BackfillShowAction @Inject constructor(
149
158
div(" divide-x divide-gray-100" ) {
150
159
dl(" divide-y divide-gray-100" ) {
151
160
rightColumnConfigurationRows.map {
152
- ConfigurationRows (id, it, backfill)
161
+ ConfigurationRows (id, it, backfill, userTimezone )
153
162
}
154
163
}
155
164
}
@@ -172,20 +181,20 @@ class BackfillShowAction @Inject constructor(
172
181
div(" my-6 space-y-4" ) {
173
182
div(" text-sm text-gray-700" ) {
174
183
span(" font-medium" ) { + """ Total backfilled ${backfill.unit ? : " units (records, segments, bytes)" } : """ }
175
- span(" font-semibold text-gray-900" ) { + """ ${totalBackfilledItems.toString().replace( Regex ( " ( \\ d)(?=( \\ d{3})+(?! \\ d)) " ), " $1, " )} """ }
184
+ span(" font-semibold text-gray-900" ) { + formatNumber(totalBackfilledItems) }
176
185
}
177
186
div(" text-sm text-gray-700" ) {
178
187
span(" font-medium" ) { + """ Total ${backfill.unit ? : " units (records, segments, bytes)" } to run: """ }
179
188
if (allPrecomputingDone) {
180
- span(" font-semibold text-gray-900" ) { + """ ${totalItemsToRun.toString().replace( Regex ( " ( \\ d)(?=( \\ d{3})+(?! \\ d)) " ), " $1, " )} """ }
189
+ span(" font-semibold text-gray-900" ) { + formatNumber(totalItemsToRun) }
181
190
} else {
182
- span(" font-semibold text-gray-900" ) { + """ at least ${totalItemsToRun.toString().replace( Regex ( " ( \\ d)(?=( \\ d{3})+(?! \\ d)) " ), " $1, " )} (still computing)""" }
191
+ span(" font-semibold text-gray-900" ) { + """ at least ${formatNumber(totalItemsToRun )} (still computing)""" }
183
192
}
184
193
}
185
194
div(" text-sm text-gray-700" ) {
186
195
span(" font-medium" ) { + """ Overall Rate: """ }
187
196
if (totalRate > 0 ) {
188
- span(" font-semibold text-gray-900" ) { + """ ${totalRate.toString().replace( Regex ( " ( \\ d)(?=( \\ d{3})+(?! \\ d)) " ), " $1, " )} #/m""" }
197
+ span(" font-semibold text-gray-900" ) { + """ ${formatNumber( totalRate.toLong() )} #/m""" }
189
198
} else {
190
199
span(" font-semibold text-gray-900" ) { + """ N/A""" }
191
200
}
@@ -362,11 +371,7 @@ class BackfillShowAction @Inject constructor(
362
371
backfill.event_logs.map { log ->
363
372
tr(" border-b border-gray-100" ) {
364
373
td(" hidden py-5 pl-8 pr-0 align-top text-wrap text-gray-700 sm:table-cell" ) {
365
- span {
366
- attributes[" data-timestamp" ] = log.occurred_at.toString()
367
- attributes[" class" ] = " localized-time"
368
- + log.occurred_at.toString().replace(" T" , " " ).dropLast(5 )
369
- }
374
+ + formatTimestampForDisplay(log.occurred_at, userTimezone)
370
375
}
371
376
td(" hidden py-5 pl-8 pr-0 align-top text-gray-700 sm:table-cell" ) { log.user?.let { + it } }
372
377
td(" hidden py-5 pl-8 pr-0 align-top text-gray-700 sm:table-cell" ) { log.partition_name?.let { + it } }
@@ -533,7 +538,7 @@ class BackfillShowAction @Inject constructor(
533
538
}
534
539
}
535
540
536
- private fun TagConsumer <* >.ConfigurationRows (id : Long , it : DescriptionListRow , backfill : GetBackfillStatusResponse ) {
541
+ private fun TagConsumer <* >.ConfigurationRows (id : Long , it : DescriptionListRow , backfill : GetBackfillStatusResponse , userTimezone : ZoneId ? ) {
537
542
div(" px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0" ) {
538
543
attributes[" data-controller" ] = " toggle"
539
544
@@ -545,11 +550,7 @@ class BackfillShowAction @Inject constructor(
545
550
546
551
// Special handling for "Created" field to add timestamp formatting
547
552
if (it.label == " Created" ) {
548
- span {
549
- attributes[" data-timestamp" ] = backfill.created_at.toString()
550
- attributes[" class" ] = " localized-time"
551
- + backfill.created_at.toString().replace(" T" , " " ).dropLast(5 )
552
- }
553
+ + formatTimestampForDisplay(backfill.created_at, userTimezone)
553
554
+ " ${it.description} "
554
555
} else {
555
556
+ it.description
@@ -729,6 +730,22 @@ class BackfillShowAction @Inject constructor(
729
730
}
730
731
}
731
732
733
+ private fun formatTimestampForDisplay (timestamp : Any , userTimezone : ZoneId ? ): String {
734
+ return try {
735
+ if (userTimezone != null ) {
736
+ val instant = Instant .parse(timestamp.toString())
737
+ val zonedDateTime = instant.atZone(userTimezone)
738
+ val formatter = DateTimeFormatter .ofPattern(" yyyy-MM-dd HH:mm:ss z" )
739
+ zonedDateTime.format(formatter)
740
+ } else {
741
+ timestamp.toString().replace(" T" , " " ).dropLast(5 )
742
+ }
743
+ } catch (e: Exception ) {
744
+ // Fall back to original format if parsing fails
745
+ timestamp.toString().replace(" T" , " " ).dropLast(5 )
746
+ }
747
+ }
748
+
732
749
private fun formatEta (etaMillis : Double ): String {
733
750
val durationSeconds = etaMillis / 1000
734
751
var temp = durationSeconds.toLong()
@@ -766,60 +783,42 @@ class BackfillShowAction @Inject constructor(
766
783
return if (sb.isEmpty()) " < 1s" else sb.toString()
767
784
}
768
785
769
- private fun formatToLocalTimestampsScript (): String = """
770
- function formatTimestamps() {
771
- document.querySelectorAll('.localized-time').forEach(function(element) {
772
- const timestamp = element.getAttribute('data-timestamp');
773
- if (timestamp) {
774
- try {
775
- const date = new Date(timestamp);
776
-
777
- if (!isNaN(date.getTime())) {
778
- let formatted;
779
- try {
780
- formatted = date.toLocaleString('en-CA', {
781
- timeZoneName: 'short',
782
- year: 'numeric',
783
- month: '2-digit',
784
- day: '2-digit',
785
- hour: '2-digit',
786
- minute: '2-digit',
787
- second: '2-digit',
788
- hour12: true
789
- });
790
- } catch (e1) {
791
- // Fallback to default locale
792
- formatted = date.toLocaleString(undefined, {
793
- timeZoneName: 'short',
794
- year: 'numeric',
795
- month: '2-digit',
796
- day: '2-digit',
797
- hour: '2-digit',
798
- minute: '2-digit',
799
- second: '2-digit',
800
- hour12: true
801
- });
802
- }
786
+ private fun timezoneDetectionScript (): String = """
787
+ (function() {
788
+ try {
789
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
790
+ document.cookie = 'user_timezone=' + encodeURIComponent(timezone) + '; path=/; max-age=31536000';
791
+ } catch (e) {
792
+ // Silently fail
793
+ }
794
+ })();
795
+ """ .trimIndent()
803
796
804
- formatted = formatted.replace(',', '').replace(/\.m\./g, 'm');
805
- element.textContent = formatted;
806
- }
807
- } catch (e) {
808
- console.error('Failed to format timestamp:', timestamp, e);
809
- }
797
+ private fun formatNumber (number : Long ): String {
798
+ return number.toString().replace(Regex (" (\\ d)(?=(\\ d{3})+(?!\\ d))" ), " $1," )
799
+ }
800
+
801
+ private fun getUserTimezone (): ZoneId ? {
802
+ return try {
803
+ val request = httpCall.get().asOkHttpRequest()
804
+ val cookieHeader = request.header(" Cookie" )
805
+
806
+ cookieHeader?.let { cookies ->
807
+ val userTimezoneCookie = cookies.split(" ;" )
808
+ .map { it.trim() }
809
+ .find { it.startsWith(" user_timezone=" ) }
810
+ ?.substringAfter(" user_timezone=" )
811
+ ?.let { URLDecoder .decode(it, " UTF-8" ) }
812
+
813
+ userTimezoneCookie?.let { timezoneString ->
814
+ ZoneId .of(timezoneString)
810
815
}
811
- });
816
+ }
817
+ } catch (e: Exception ) {
818
+ // Fall back to UTC if there's any issue
819
+ null
812
820
}
813
-
814
- formatTimestamps();
815
-
816
- document.addEventListener('DOMContentLoaded', formatTimestamps);
817
- document.addEventListener('turbo:frame-load', formatTimestamps);
818
- setTimeout(formatTimestamps, 100);
819
-
820
- // Run periodically to catch any missed updates
821
- setInterval(formatTimestamps, 1000);
822
- """ .trimIndent()
821
+ }
823
822
824
823
companion object {
825
824
private const val PATH = " /backfills/{id}"
0 commit comments