handle panics in importer, translate messages and fix bugs

This commit is contained in:
Abhinav Raut
2026-01-09 03:26:22 +05:30
parent 18f5ff1868
commit 173493a0ed
12 changed files with 465 additions and 363 deletions
+31 -34
View File
@@ -17,13 +17,13 @@ func handleImportAgents(r *fastglue.Request) error {
file, err := r.RequestCtx.FormFile("file")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "No file provided", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.file}"), nil, envelope.InputError)
}
fileContent, err := file.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to read file", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
}
defer fileContent.Close()
@@ -32,11 +32,11 @@ func handleImportAgents(r *fastglue.Request) error {
records, err := reader.ReadAll()
if err != nil {
app.lo.Error("error parsing CSV", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid CSV format", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.csvFile}"), nil, envelope.InputError)
}
if len(records) < 2 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "CSV must contain headers and at least one data row", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("importer.csvMustContainHeadersAndData"), nil, envelope.InputError)
}
err = app.importer.Submit("agents", func() error {
@@ -44,23 +44,19 @@ func handleImportAgents(r *fastglue.Request) error {
})
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusConflict, err.Error(), nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("importer.importAlreadyInProgress"), nil, envelope.GeneralError)
}
return r.SendEnvelope(map[string]string{
"message": "Import started",
})
return r.SendEnvelope(true)
}
// handleGetAgentImportStatus returns current import status
func handleGetAgentImportStatus(r *fastglue.Request) error {
var app = r.Context.(*App)
status, err := app.importer.GetStatus("agents")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(status)
}
@@ -72,7 +68,7 @@ func processAgentImport(app *App, records [][]string) error {
}
// Validate required columns
required := []string{"first_name", "last_name", "email", "roles", "teams"}
required := []string{"first_name", "last_name", "email", "roles"}
for _, col := range required {
if _, ok := headerMap[col]; !ok {
return fmt.Errorf("missing required column: %s", col)
@@ -107,7 +103,7 @@ func processAgentImport(app *App, records [][]string) error {
// Process each row
for i, record := range records[1:] {
rowNum := i + 2
rowNum := i + 1
// Parse fields
firstName := getField(record, headerMap, "first_name")
@@ -117,7 +113,7 @@ func processAgentImport(app *App, records [][]string) error {
teamsStr := getField(record, headerMap, "teams")
// Validate required fields
if firstName == "" || lastName == "" || email == "" || rolesStr == "" || teamsStr == "" {
if firstName == "" || lastName == "" || email == "" || rolesStr == "" {
app.importer.UpdateCounts("agents", 0, 0, 1)
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - missing required fields", rowNum))
continue
@@ -145,18 +141,21 @@ func processAgentImport(app *App, records [][]string) error {
continue
}
// Parse and validate teams
// Parse and validate teams (optional)
teams := parseList(teamsStr)
if len(teams) == 0 {
app.importer.UpdateCounts("agents", 0, 0, 1)
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - at least one team required", rowNum))
continue
if len(teams) > 0 {
invalidTeams := findInvalid(teams, validTeams)
if len(invalidTeams) > 0 {
app.importer.UpdateCounts("agents", 0, 0, 1)
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - invalid team(s): %s", rowNum, strings.Join(invalidTeams, ", ")))
continue
}
}
invalidTeams := findInvalid(teams, validTeams)
if len(invalidTeams) > 0 {
// Check if agent already exists
if _, err := app.user.GetAgent(0, email); err == nil {
app.importer.UpdateCounts("agents", 0, 0, 1)
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - invalid team(s): %s", rowNum, strings.Join(invalidTeams, ", ")))
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - email already exists", rowNum))
continue
}
@@ -164,19 +163,17 @@ func processAgentImport(app *App, records [][]string) error {
agent, err := app.user.CreateAgent(firstName, lastName, email, roles)
if err != nil {
app.importer.UpdateCounts("agents", 0, 0, 1)
if strings.Contains(strings.ToLower(err.Error()), "email") && strings.Contains(strings.ToLower(err.Error()), "exists") {
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - email already exists", rowNum))
} else {
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - failed to create agent", rowNum))
}
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - failed to create agent", rowNum))
continue
}
// Assign teams
if err := app.team.UpsertUserTeams(agent.ID, teams); err != nil {
app.importer.UpdateCounts("agents", 0, 0, 1)
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - team assignment failed", rowNum))
continue
// Assign teams (if provided)
if len(teams) > 0 {
if err := app.team.UpsertUserTeams(agent.ID, teams); err != nil {
app.importer.UpdateCounts("agents", 0, 0, 1)
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - team assignment failed", rowNum))
continue
}
}
app.importer.UpdateCounts("agents", 0, 1, 0)
@@ -185,8 +182,8 @@ func processAgentImport(app *App, records [][]string) error {
// Final summary
status, _ := app.importer.GetStatus("agents")
app.importer.AddLog("agents", fmt.Sprintf("Import completed: %d successful, %d failed out of %d total",
status.Success, status.Errors, status.Total))
app.importer.AddLog("agents", fmt.Sprintf("Import completed: %d of %d successful, %d failed",
status.Success, status.Total, status.Errors))
return nil
}
+9
View File
@@ -26,6 +26,7 @@ import (
"github.com/abhinavxd/libredesk/internal/conversation/status"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/importer"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
@@ -954,6 +955,14 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati
return m
}
// initImporter inits the importer manager.
func initImporter(i18n *i18n.I18n) *importer.Importer {
return importer.New(importer.Opts{
Lo: initLogger("importer"),
I18n: i18n,
})
}
// initNotifDispatcher initializes the notification dispatcher.
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub) *notifier.Dispatcher {
return notifier.NewDispatcher(notifier.DispatcherOpts{
+3 -1
View File
@@ -258,7 +258,7 @@ func main() {
conversation: conversation,
automation: automation,
businessHours: businessHours,
importer: importer.NewImporter(),
importer: initImporter(i18n),
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
@@ -320,6 +320,8 @@ func main() {
conversation.Close()
colorlog.Red("Shutting down SLA...")
sla.Close()
colorlog.Red("Shutting down importer...")
app.importer.Close()
colorlog.Red("Shutting down database...")
db.Close()
colorlog.Red("Shutting down redis...")
+9
View File
@@ -251,6 +251,13 @@ const setPassword = (data) => http.post('/api/v1/agents/set-password', data, {
}
})
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
const importAgents = (data) =>
http.post('/api/v1/agents/import', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const getAgentImportStatus = () => http.get('/api/v1/agents/import/status')
const createUser = (data) =>
http.post('/api/v1/agents', data, {
headers: {
@@ -483,6 +490,8 @@ const deleteAllNotifications = () => http.delete('/api/v1/notifications')
export default {
login,
deleteUser,
importAgents,
getAgentImportStatus,
resetPassword,
setPassword,
getTags,
@@ -0,0 +1,31 @@
<template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-700"
@click="copy"
>
<Check v-if="copied" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
</template>
<script setup>
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Copy, Check } from 'lucide-vue-next'
const props = defineProps({
text: { type: String, required: true }
})
const copied = ref(false)
const copy = async () => {
await navigator.clipboard.writeText(props.text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 1500)
}
</script>
@@ -0,0 +1,267 @@
<template>
<div>
<Button variant="secondary" @click="openDialog">
{{ $t('globals.terms.import') }}
</Button>
<Dialog v-model:open="showDialog">
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{{
$t('globals.messages.import', {
name: $t(entityKey, 2).toLowerCase()
})
}}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<div v-if="!loading && !status" class="space-y-4">
<div
@click="$refs.fileInput.click()"
class="flex items-center h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<span class="flex-1 truncate" :class="!file && 'text-muted-foreground'">
{{
file
? file.name
: $t('globals.messages.selectFile', { name: $t('globals.terms.csvFile') })
}}
</span>
<Upload class="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
<input
type="file"
accept=".csv"
@change="onFileSelect"
ref="fileInput"
class="hidden"
/>
<Alert>
<AlertTitle>{{ $t('importer.requiredCSVFormat') }}</AlertTitle>
<AlertDescription class="mt-2">
<div class="bg-muted p-3 rounded text-xs font-mono overflow-x-auto leading-relaxed">
<div>first_name,last_name,email,roles,teams</div>
<div>John,Doe,john@example.com,Agent,Sales</div>
<div>Jane,Smith,jane@example.com,Admin,Support</div>
<div>Bob,Test,bob@example.com,"Agent,Admin",Support</div>
</div>
<p class="text-xs mt-2 text-muted-foreground">
{{ $t('importer.caseSensitiveNote') }}
</p>
</AlertDescription>
</Alert>
</div>
<!-- Loading spinner -->
<div v-if="loading" class="flex justify-center py-8">
<Spinner />
</div>
<!-- Logs -->
<div v-if="status?.logs?.some((l) => l && l.trim())" class="space-y-4">
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('globals.terms.log', 2) }}</p>
<Card class="p-0 overflow-hidden">
<div class="relative">
<CopyButton :text="status.logs.join('\n')" class="absolute top-2 right-2 z-10" />
<div
class="bg-black text-white p-4 text-xs font-mono min-h-24 max-h-60 overflow-y-auto space-y-1 logs-scroll-container"
>
<div v-for="(log, idx) in status.logs.filter((l) => l && l.trim())" :key="idx">
{{ log }}
</div>
</div>
</div>
</Card>
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertTitle>{{ $t('globals.terms.error') }}</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button v-if="complete" @click="resetAndClose">
{{ $t('globals.messages.close') }}
</Button>
<template v-else>
<Button variant="outline" @click="closeDialog" :disabled="loading || status?.running">
{{ $t('globals.messages.cancel') }}
</Button>
<Button @click="startImport" :disabled="!file || loading || status?.running">
{{ $t('globals.terms.import') }}
</Button>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import { Upload } from 'lucide-vue-next'
import { Spinner } from '@/components/ui/spinner'
import CopyButton from '@/components/button/CopyButton.vue'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const { t } = useI18n()
const emitter = useEmitter()
const props = defineProps({
entityKey: { type: String, required: true },
uploadFn: { type: Function, required: true },
getStatusFn: { type: Function, required: true }
})
const showDialog = ref(false)
const file = ref(null)
const loading = ref(false)
const status = ref(null)
const error = ref('')
const pollInterval = ref(null)
const complete = computed(() => status.value && !status.value.running)
const emit = defineEmits(['import-complete'])
const onFileSelect = (e) => {
file.value = e.target.files[0]
error.value = ''
}
const openDialog = async () => {
showDialog.value = true
loading.value = true
// Check if import already running
try {
const res = await props.getStatusFn()
if (res.data.data?.running) {
startPolling(res.data.data)
return
}
} catch {
// no existing import
}
loading.value = false
}
const startImport = async () => {
if (!file.value) return
error.value = ''
loading.value = true
const formData = new FormData()
formData.append('file', file.value)
try {
await props.uploadFn(formData)
startPolling()
} catch (err) {
error.value = handleHTTPError(err).message
loading.value = false
status.value = null
}
}
const fetchStatus = async () => {
try {
const res = await props.getStatusFn()
status.value = res.data.data
// Auto-scroll logs to bottom
scrollLogsToBottom()
if (!status.value.running) {
stopPolling()
}
} catch (err) {
if (err.response?.status !== 404) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(err).message
})
}
}
}
const scrollLogsToBottom = () => {
nextTick(() => {
const logsContainer = document.querySelector('.logs-scroll-container')
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight
}
})
}
const startPolling = async (initialStatus = null) => {
if (initialStatus) {
status.value = initialStatus
} else {
await fetchStatus()
}
loading.value = false
pollInterval.value = setInterval(fetchStatus, 1000)
}
const stopPolling = () => {
if (pollInterval.value) {
clearInterval(pollInterval.value)
pollInterval.value = null
}
}
const closeDialog = () => {
if (
(loading.value || status.value?.running) &&
!confirm(t('globals.messages.inProgressConfirmClose', { action: t('globals.terms.import') }))
)
return
stopPolling()
resetState()
showDialog.value = false
}
const resetAndClose = () => {
stopPolling()
resetState()
showDialog.value = false
emit('import-complete')
}
const resetState = () => {
file.value = null
loading.value = false
status.value = null
error.value = ''
}
onBeforeUnmount(() => {
stopPolling()
})
// Reset state when dialog is closed
watch(showDialog, (open) => {
if (!open) {
stopPolling()
resetState()
}
})
</script>
@@ -1,297 +0,0 @@
<template>
<div>
<Button variant="secondary" @click="showDialog = true">
Import Agents
</Button>
<Dialog v-model:open="showDialog">
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Import Agents</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<!-- File Upload Section -->
<div v-if="!importing && !complete" class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">Select CSV file</label>
<div
@click="$refs.fileInput.click()"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<span class="flex-1 truncate" :class="!file && 'text-muted-foreground'">
{{ file ? file.name : 'Choose a CSV file...' }}
</span>
<svg
class="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round" ``
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<input
type="file"
accept=".csv"
@change="onFileSelect"
ref="fileInput"
class="hidden"
/>
</div>
<AlertTitle>Required CSV format</AlertTitle>
<Alert>
<AlertDescription>
<p class="text-xs mb-2">Example CSV:</p>
<div class="bg-muted p-2 rounded text-xs font-mono overflow-x-auto">
<div>first_name,last_name,email,roles,teams</div>
<div>John,Doe,john@example.com,Agent,Sales</div>
<div>Jane,Smith,jane@example.com,Admin,Support</div>
<div>Bob,Test,bob@example.com,"Agent,Admin",Support</div>
</div>
<p class="text-xs mt-2 text-muted-foreground">
Roles and teams must match database values exactly (case-sensitive)
</p>
</AlertDescription>
</Alert>
<Button @click="startImport"
:disabled="!file"
class="w-full"
>
Start Import
</Button>
</div>
<!-- Progress Section -->
<div v-if="status" class="space-y-4">
<div v-if="importing" class="flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">Importing agents...</span>
</div>
<Alert
v-if="complete"
class="bg-green-50 dark:bg-green-950 border-green-200"
>
<AlertTitle class="text-green-600">Success!</AlertTitle>
<AlertDescription class="text-green-600">
Import completed: {{ status.success }} successful,
{{ status.errors }} failed out of {{ status.total }} total
</AlertDescription>
</Alert>
<div>
<p class="text-sm font-medium mb-2">Import logs</p>
<Card class="p-3">
<div class="relative">
<Button
variant="ghost"
size="icon"
class="absolute top-2 right-2 h-7 w-7 text-muted-foreground hover:text-foreground z-10"
@click="copyLogs"
>
<Check v-if="copied" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<div
class="bg-black text-white p-3 pt-8 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1 logs-scroll-container"
>
<div v-for="(log, idx) in status.logs" :key="idx">
{{ log }}
</div>
</div>
</div>
</Card>
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button v-if="complete" @click="resetAndClose">
Done
</Button>
<Button
variant="outline"
@click="closeDialog"
:disabled="importing"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Spinner } from '@/components/ui/spinner'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import axios from 'axios'
import { Copy, Check } from "lucide-vue-next"
const showDialog = ref(false)
const file = ref(null)
const importing = ref(false)
const complete = ref(false)
const copied = ref(false)
const status = ref(null)
const error = ref('')
const pollInterval = ref(null)
const emit = defineEmits(['import-complete'])
const getCSRFToken = () => {
const name = 'csrf_token='
const cookies = document.cookie.split(';')
for (let i = 0; i < cookies.length; i++) {
let c = cookies[i].trim()
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length)
}
}
return ''
}
const onFileSelect = (e) => {
file.value = e.target.files[0]
error.value = ''
}
const startImport = async () => {
if (!file.value) return
error.value = ''
importing.value = true
// Initialize empty status to show logs area immediately
status.value = {
running: true,
logs: ['Uploading CSV file...'],
total: 0,
success: 0,
errors: 0
}
const formData = new FormData()
formData.append('file', file.value)
try {
await axios.post('/api/v1/agents/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'X-CSRFTOKEN': getCSRFToken()
}
})
// Update log after successful upload
status.value.logs.push('CSV uploaded successfully, starting import...')
startPolling()
} catch (err) {
error.value = err.response?.data?.message || err.message || 'Upload failed'
importing.value = false
status.value = null
}
}
const fetchStatus = async () => {
try {
const res = await axios.get('/api/v1/agents/import/status')
status.value = res.data.data
// Auto-scroll logs to bottom
scrollLogsToBottom()
if (!status.value.running) {
stopPolling()
importing.value = false
complete.value = true
}
} catch (err) {
if (err.response?.status !== 404) {
console.error('Poll error:', err)
}
}
}
const scrollLogsToBottom = () => {
// Use nextTick to ensure DOM is updated before scrolling
import('vue').then(({ nextTick }) => {
nextTick(() => {
const logsContainer = document.querySelector('.logs-scroll-container')
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight
}
})
})
}
const startPolling = () => {
pollInterval.value = setInterval(fetchStatus, 1000)
}
const stopPolling = () => {
if (pollInterval.value) {
clearInterval(pollInterval.value)
pollInterval.value = null
}
}
const closeDialog = () => {
if (importing.value && !confirm('Import in progress. Close?')) return
stopPolling()
resetState()
showDialog.value = false
}
const resetAndClose = () => {
stopPolling()
resetState()
showDialog.value = false
emit('import-complete')
}
const resetState = () => {
file.value = null
importing.value = false
complete.value = false
status.value = null
error.value = ''
}
onBeforeUnmount(() => {
stopPolling()
})
const copyLogs = async () => {
if (!status.value?.logs?.length) return
await navigator.clipboard.writeText(
status.value.logs.join("\n")
)
copied.value = true
setTimeout(() => {
copied.value = false
}, 1500)
}
</script>
@@ -29,6 +29,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, enabled ? t('globals.messages.yes') : t('globals.messages.no'))
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
accessorKey: 'updated_at',
header: function () {
@@ -2,7 +2,12 @@
<Spinner v-if="isLoading" />
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
<div class="flex justify-end mb-5 gap-2">
<Importer @import-complete="getData" />
<Importer
entity-key="globals.terms.agent"
:upload-fn="api.importAgents"
:get-status-fn="api.getAgentImportStatus"
@import-complete="getData"
/>
<router-link :to="{ name: 'new-agent' }">
<Button>{{
$t('globals.messages.new', {
@@ -28,7 +33,8 @@ import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useUsersStore } from '@/stores/users'
import { useI18n } from 'vue-i18n'
import Importer from '@/components/ui/importer/Importer.vue'
import Importer from '@/components/importer/Importer.vue'
import api from '@/api'
const isLoading = ref(false)
const usersStore = useUsersStore()
+1 -1
View File
@@ -5,7 +5,7 @@
</template>
<template #help>
<p>Manage your support agents, their roles and permissions and teams.</p>
<p>Manage support agents, roles, permissions and teams.</p>
</template>
</AdminPageWithHelp>
</template>
+12 -1
View File
@@ -18,8 +18,10 @@
"globals.terms.permission": "Permission | Permissions",
"globals.terms.request": "Request | Requests",
"globals.terms.file": "File | Files",
"globals.terms.csvFile": "CSV file | CSV files",
"globals.terms.actor": "Actor | Actors",
"globals.terms.page": "Page | Pages",
"globals.terms.log": "Log | Logs",
"globals.terms.activityLog": "Activity log | Activity logs",
"activityLog.type.agentLogin": "Agent login",
"activityLog.type.agentLogout": "Agent logout",
@@ -331,6 +333,10 @@
"globals.messages.upload": "Upload",
"globals.messages.back": "Back",
"globals.messages.close": "Close",
"globals.messages.import": "Import {name}",
"globals.messages.selectFile": "Select {name}",
"globals.messages.uploadFailed": "Upload failed",
"globals.messages.inProgressConfirmClose": "{action} in progress. Close?",
"globals.messages.apply": "Apply {name}",
"globals.messages.reset": "Reset {name}",
"globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",
@@ -720,5 +726,10 @@
"contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
"setup.completeYourSetup": "Complete your setup",
"setup.createFirstInbox": "Create your first inbox",
"setup.inviteTeammates": "Invite teammates"
"setup.inviteTeammates": "Invite teammates",
"importer.requiredCSVFormat": "Required CSV format",
"importer.importCompleted": "Import completed: {success} of {total} successful, {errors} failed",
"importer.csvMustContainHeadersAndData": "CSV must contain headers and at least one data row",
"importer.importAlreadyInProgress": "Import already in progress",
"importer.caseSensitiveNote": "Roles and teams must match exactly (case-sensitive)"
}
+85 -27
View File
@@ -1,12 +1,18 @@
package importer
import (
"context"
"fmt"
"sync"
"time"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/knadh/go-i18n"
"github.com/zerodha/logf"
)
type JobStatus struct {
// Job represents the status of an import job.
type Job struct {
Running bool `json:"running"`
Logs []string `json:"logs"`
Total int `json:"total"`
@@ -16,28 +22,49 @@ type JobStatus struct {
EndedAt time.Time `json:"ended_at"`
}
// Importer manages background import jobs.
type Importer struct {
jobs map[string]*JobStatus
mu sync.RWMutex
lo *logf.Logger
i18n *i18n.I18n
jobs map[string]*Job
mu sync.RWMutex
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
func NewImporter() *Importer {
// Opts contains options for initializing the Importer.
type Opts struct {
Lo *logf.Logger
I18n *i18n.I18n
}
// New creates and returns a new instance of the Importer.
func New(opts Opts) *Importer {
ctx, cancel := context.WithCancel(context.Background())
i := &Importer{
jobs: make(map[string]*JobStatus),
lo: opts.Lo,
i18n: opts.I18n,
jobs: make(map[string]*Job),
ctx: ctx,
cancel: cancel,
}
i.wg.Add(1)
go i.cleanUp()
return i
}
// Submit submits a new import job for execution.
func (i *Importer) Submit(namespace string, fn func() error) error {
i.mu.Lock()
if status, exists := i.jobs[namespace]; exists && status.Running {
i.mu.Unlock()
return fmt.Errorf("import already running for namespace: %s", namespace)
return envelope.NewError(envelope.ConflictError,
i.i18n.T("importer.importAlreadyInProgress"), nil)
}
status := &JobStatus{
status := &Job{
Running: true,
Logs: []string{},
StartedAt: time.Now(),
@@ -45,53 +72,53 @@ func (i *Importer) Submit(namespace string, fn func() error) error {
i.jobs[namespace] = status
i.mu.Unlock()
i.lo.Info("starting import job", "namespace", namespace)
go func() {
defer func() {
// Recover from panics
if r := recover(); r != nil {
i.mu.Lock()
status.Logs = append(status.Logs, fmt.Sprintf("Panic: %v", r))
i.mu.Unlock()
i.lo.Error("import job panicked", "namespace", namespace, "panic", r)
}
i.mu.Lock()
status.Running = false
status.EndedAt = time.Now()
i.mu.Unlock()
i.lo.Info("import job completed", "namespace", namespace,
"total", status.Total, "success", status.Success, "errors", status.Errors)
}()
if err := fn(); err != nil {
i.mu.Lock()
status.Logs = append(status.Logs, fmt.Sprintf("Error: %v", err))
i.mu.Unlock()
i.lo.Error("import job failed", "namespace", namespace, "error", err)
}
}()
return nil
}
func (i *Importer) GetStatus(namespace string) (*JobStatus, error) {
// GetStatus returns the status of an import job.
func (i *Importer) GetStatus(namespace string) (*Job, error) {
i.mu.RLock()
defer i.mu.RUnlock()
status, exists := i.jobs[namespace]
if !exists {
return nil, fmt.Errorf("no import job found for namespace: %s", namespace)
return nil, envelope.NewError(envelope.NotFoundError,
i.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.import}"), nil)
}
return status, nil
}
func (i *Importer) cleanUp() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
i.mu.Lock()
now := time.Now()
for namespace, status := range i.jobs {
if !status.Running && now.Sub(status.EndedAt) > 24*time.Hour {
delete(i.jobs, namespace)
}
}
i.mu.Unlock()
}
}
// AddLog appends a log message to the job status
// AddLog appends a log message to the job status.
func (i *Importer) AddLog(namespace, message string) {
i.mu.Lock()
defer i.mu.Unlock()
@@ -101,7 +128,7 @@ func (i *Importer) AddLog(namespace, message string) {
}
}
// UpdateCounts updates the success/error counts and total
// UpdateCounts updates the success/error counts and total.
func (i *Importer) UpdateCounts(namespace string, total, success, errors int) {
i.mu.Lock()
defer i.mu.Unlock()
@@ -118,3 +145,34 @@ func (i *Importer) UpdateCounts(namespace string, total, success, errors int) {
}
}
}
// Close gracefully shuts down the importer.
func (i *Importer) Close() {
i.cancel()
i.wg.Wait()
}
// cleanUp periodically removes old completed jobs.
func (i *Importer) cleanUp() {
defer i.wg.Done()
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-i.ctx.Done():
return
case <-ticker.C:
i.mu.Lock()
now := time.Now()
for namespace, status := range i.jobs {
if !status.Running && now.Sub(status.EndedAt) > 1*time.Hour {
delete(i.jobs, namespace)
i.lo.Debug("cleaned up old import job", "namespace", namespace)
}
}
i.mu.Unlock()
}
}
}