Add ongoing sync indicator to dashboard

This means I can remove the "loading" state of the dashboard.
This commit is contained in:
Eli Ribble 2026-01-06 15:32:26 +00:00
parent 53ee020fe0
commit 05b3caaa73
4 changed files with 37 additions and 86 deletions

View file

@ -39,6 +39,8 @@ import (
"github.com/uber/h3-go/v4" "github.com/uber/h3-go/v4"
) )
var syncStatusByOrg map[int32]bool
// When the API responds that the token is now invalidated // When the API responds that the token is now invalidated
type InvalidatedTokenError struct{} type InvalidatedTokenError struct{}
@ -129,6 +131,10 @@ func generateCodeVerifier() string {
return base64.RawURLEncoding.EncodeToString(bytes) return base64.RawURLEncoding.EncodeToString(bytes)
} }
func isSyncOngoing(org_id int32) bool {
return syncStatusByOrg[org_id]
}
// Find out what we can about this user // Find out what we can about this user
func updateArcgisUserData(ctx context.Context, user *models.User, access_token string, access_token_expires time.Time, refresh_token string, refresh_token_expires time.Time) { func updateArcgisUserData(ctx context.Context, user *models.User, access_token string, access_token_expires time.Time, refresh_token string, refresh_token_expires time.Time) {
client := arcgis.NewArcGIS( client := arcgis.NewArcGIS(
@ -151,40 +157,13 @@ func updateArcgisUserData(ctx context.Context, user *models.User, access_token s
log.Error().Err(err).Msg("Failed to update oauth token portal data") log.Error().Err(err).Msg("Failed to update oauth token portal data")
return return
} }
var org *models.Organization org := user.R.Organization
orgs, err := models.Organizations.Query(models.SelectWhere.Organizations.ArcgisName.EQ(portal.Name)).All(ctx, db.PGInstance.BobDB) err = org.Update(ctx, db.PGInstance.BobDB, &models.OrganizationSetter{
switch len(orgs) {
case 0:
setter := models.OrganizationSetter{
Name: omitnull.From(portal.Name),
ArcgisID: omitnull.From(portal.User.OrgID), ArcgisID: omitnull.From(portal.User.OrgID),
ArcgisName: omitnull.From(portal.Name), ArcgisName: omitnull.From(portal.Name),
} })
org, err = models.Organizations.Insert(&setter).One(ctx, db.PGInstance.BobDB)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to create new organization") log.Error().Err(err).Int32("id", user.R.Organization.ID).Msg("Failed to update organization's arcgis info")
return
}
log.Info().Int("org_id", int(org.ID)).Msg("Created new organization")
case 1:
org = orgs[0]
log.Info().Msg("Organization already exists")
default:
log.Error().Msg("Got too many organizations, bailing")
return
}
if err != nil {
debug.LogErrorTypeInfo(err)
if errors.Is(err, pgx.ErrNoRows) {
} else {
log.Error().Err(err).Msg("Failed to query for existing org")
return
}
}
err = org.AttachUser(ctx, db.PGInstance.BobDB, user)
if err != nil {
log.Error().Err(err).Int("user_id", int(user.ID)).Int("org_id", int(org.ID)).Msg("Failed to attach user to organization")
return return
} }
@ -292,6 +271,7 @@ func redirectURL() string {
// This is a goroutine that is in charge of getting Fieldseeker data and keeping it fresh. // This is a goroutine that is in charge of getting Fieldseeker data and keeping it fresh.
func refreshFieldseekerData(ctx context.Context, newOauthCh <-chan struct{}) { func refreshFieldseekerData(ctx context.Context, newOauthCh <-chan struct{}) {
syncStatusByOrg = make(map[int32]bool, 0)
for { for {
workerCtx, cancel := context.WithCancel(context.Background()) workerCtx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup var wg sync.WaitGroup
@ -448,6 +428,7 @@ func periodicallyExportFieldseeker(ctx context.Context, org *models.Organization
return fmt.Errorf("Failed to get oauth for org: %w", err) return fmt.Errorf("Failed to get oauth for org: %w", err)
} }
err = exportFieldseekerData(ctx, org, oauth) err = exportFieldseekerData(ctx, org, oauth)
syncStatusByOrg[org.ID] = false
if err != nil { if err != nil {
return fmt.Errorf("Failed to export Fieldseeker data: %w", err) return fmt.Errorf("Failed to export Fieldseeker data: %w", err)
} }
@ -458,6 +439,7 @@ func periodicallyExportFieldseeker(ctx context.Context, org *models.Organization
} }
func exportFieldseekerData(ctx context.Context, org *models.Organization, oauth *models.OauthToken) error { func exportFieldseekerData(ctx context.Context, org *models.Organization, oauth *models.OauthToken) error {
log.Info().Msg("Update Fieldseeker data") log.Info().Msg("Update Fieldseeker data")
syncStatusByOrg[org.ID] = true
var err error var err error
ar := arcgis.NewArcGIS( ar := arcgis.NewArcGIS(
arcgis.AuthenticatorOAuth{ arcgis.AuthenticatorOAuth{

21
html.go
View file

@ -34,7 +34,6 @@ var embeddedFiles embed.FS
var ( var (
cell = newBuiltTemplate("cell", "authenticated") cell = newBuiltTemplate("cell", "authenticated")
dashboard = newBuiltTemplate("dashboard", "authenticated") dashboard = newBuiltTemplate("dashboard", "authenticated")
dashboardLoading = newBuiltTemplate("dashboard-loading", "authenticated")
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated") oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
settings = newBuiltTemplate("settings", "authenticated") settings = newBuiltTemplate("settings", "authenticated")
source = newBuiltTemplate("source", "authenticated") source = newBuiltTemplate("source", "authenticated")
@ -142,6 +141,7 @@ type ContentDashboard struct {
CountMosquitoSources int CountMosquitoSources int
CountServiceRequests int CountServiceRequests int
Geo template.JS Geo template.JS
IsSyncOngoing bool
LastSync *time.Time LastSync *time.Time
MapData ComponentMap MapData ComponentMap
Org string Org string
@ -315,10 +315,6 @@ func htmlCell(ctx context.Context, w http.ResponseWriter, user *models.User, c i
func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User) { func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User) {
org, err := user.Organization().One(ctx, db.PGInstance.BobDB) org, err := user.Organization().One(ctx, db.PGInstance.BobDB)
if err != nil { if err != nil {
if err.Error() == "sql: no rows in result set" {
htmlDashboardLoading(ctx, w, user)
return
}
respondError(w, "Failed to get org", err, http.StatusInternalServerError) respondError(w, "Failed to get org", err, http.StatusInternalServerError)
return return
} }
@ -332,6 +328,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
} else { } else {
lastSync = &sync.Created lastSync = &sync.Created
} }
is_syncing := isSyncOngoing(org.ID)
inspectionCount, err := org.Mosquitoinspections().Count(ctx, db.PGInstance.BobDB) inspectionCount, err := org.Mosquitoinspections().Count(ctx, db.PGInstance.BobDB)
if err != nil { if err != nil {
respondError(w, "Failed to get inspection count", err, http.StatusInternalServerError) respondError(w, "Failed to get inspection count", err, http.StatusInternalServerError)
@ -370,6 +367,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
CountInspections: int(inspectionCount), CountInspections: int(inspectionCount),
CountMosquitoSources: int(sourceCount), CountMosquitoSources: int(sourceCount),
CountServiceRequests: int(serviceCount), CountServiceRequests: int(serviceCount),
IsSyncOngoing: is_syncing,
LastSync: lastSync, LastSync: lastSync,
MapData: ComponentMap{ MapData: ComponentMap{
MapboxToken: MapboxToken, MapboxToken: MapboxToken,
@ -381,19 +379,6 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
renderOrError(w, dashboard, data) renderOrError(w, dashboard, data)
} }
// A dashboard to show while we are still downloading information about their organization
func htmlDashboardLoading(ctx context.Context, w http.ResponseWriter, user *models.User) {
userContent, err := contentForUser(ctx, user)
if err != nil {
respondError(w, "Failed to get user context", err, http.StatusInternalServerError)
return
}
data := ContentDashboardLoading{
User: userContent,
}
renderOrError(w, dashboardLoading, data)
}
func htmlMock(t string, w http.ResponseWriter, code string) { func htmlMock(t string, w http.ResponseWriter, code string) {
data := ContentMock{ data := ContentMock{
DistrictName: "Delta MVCD", DistrictName: "Delta MVCD",

View file

@ -1,26 +0,0 @@
{{template "authenticated.html" .}}
{{define "title"}}Dash{{end}}
{{define "extraheader"}}
<script src='https://api.mapbox.com/mapbox-gl-js/v3.17.0-beta.1/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v3.17.0-beta.1/mapbox-gl.css' rel='stylesheet' />
<script>
function onLoad() {
console.log("Map init done.");
}
window.addEventListener("load", onLoad);
</script>
<style>
body {
background-color: #f8f9fa;
}
.dashboard-container {
padding: 20px 0;
}
</style>
{{end}}
{{define "content"}}
<div class="container dashboard-container">
<p>We're downloading the data we need, hold on. This page will refresh every 10 seconds automatically until we've got it.</p>
</div>
{{end}}

View file

@ -163,6 +163,10 @@ body {
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
} }
.syncing {
color: #28a745;
animation: fa-spin 2s linear infinite;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -174,10 +178,16 @@ body {
<p class="text-muted">Overview of mosquito control activities in your district</p> <p class="text-muted">Overview of mosquito control activities in your district</p>
</div> </div>
<div class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end"> <div class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end">
{{ if .IsSyncOngoing }}
<p class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2 syncing"></i>Syncing now...
</p>
{{ else }}
<p class="last-refreshed mb-0"> <p class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2"></i>Last updated: <span id="last-refreshed-time">{{ .LastSync | timeSince }}</span> <i class="fas fa-sync-alt me-2"></i>Last updated: <span id="last-refreshed-time">{{ .LastSync | timeSince }}</span>
<button class="btn btn-sm btn-outline-primary ms-3">Refresh Data</button> <button class="btn btn-sm btn-outline-primary ms-3">Refresh Data</button>
</p> </p>
{{ end }}
</div> </div>
</div> </div>