Skip to main content
mikes0011
Brainy
May 12, 2025 8:42 PM UTC
Question

New tool: Login Audit Log GUI (Appian interface)

Hey all!

I'm sharing the Appian interface I recently built to give better visibility into the system's recent internal Login Audit files (instead of constantly downloading and opening CSV files) - particularly helpful if you're an O&M user and/or diagnosing login issues for any other reason.

This interface relies on the Log Reader plug-in (and also uses the Date and Time Utilites plug-in for a certain transform on timestamps).  That said, I can't say how it'll perform on multi-server environments, since I don't have one to try it on.

The entire interface is self-contained, with the sole exception of a custom rule I wrote that utilizes the internal "User" record to take an entered username and get the system's properly-cased username (since there is some inconsistent case sensitivity around usernames and there's no other way I could come up with to tell what the 'real' version is).  I'll include that expression rule code after the main interface code.

a!localVariables(
  local!relevantDay: today(),
  local!isViewingToday: local!relevantDay = today(),

  local!dayString: if(
    local!isViewingToday,
    "",
    "." & text(local!relevantDay, "yyyy-mm-dd")
  ),
  local!refreshCounter: 0,
  
  /* use this (optionally) to filter out a service account that might get a disproportionate number of hits per day.  if none, set it to a nonsense string. */
  local!usernameToFilterOut: "api.test.account.name.asdf",

  local!currentLog: a!refreshVariable(
    value: readcsvlogpagingwithheaders(
      csvPath: "login-audit.csv" & local!dayString,
      startIndex: 1,
      batchSize: 1000,
      headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "secondaryUuid"},
      filterColumName: "username",
      filterOperator: "!=",
      filterValue: local!usernameToFilterOut
    ),
    refreshOnVarChange: {local!refreshCounter}
  ),
  local!backgroundAutoRefreshedTotalCount: a!refreshVariable(
    value: if(
      local!isViewingToday,
      readcsvlogpagingwithheaders(
        csvPath: "login-audit.csv", startIndex: 1, batchSize: 1,
        headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
        filterColumName: "username", filterOperator: "!=", filterValue: local!usernameToFilterOut
      ).totalCount,
      null()
    ),
    refreshInterval: 0.5
  ),
  local!isTotalCountStale: and(
    local!isViewingToday,
    local!backgroundAutoRefreshedTotalCount <> local!currentLog.totalCount
  ),

  /* using brute force a bit here to tell if any logs exist for any of the past 4 days */
  local!hasPreviousDay: or(
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-1, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-2, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-3, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-4, "yyyy-mm-dd") ).totalCount > -1
  ),

  local!powerBiCounter: a!refreshVariable(
    value: readcsvlogpagingwithheaders(
      csvPath: "login-audit.csv" & local!dayString,
      startIndex: 1,
      batchSize: 1,
      headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
      filterColumName: "username",
      filterOperator: "=",
      filterValue: local!usernameToFilterOut
    ).totalCount,
    refreshOnVarChange: {local!refreshCounter}
  ),

  local!splitVals: a!forEach(
    local!currentLog.rows,
    split(fv!item, ",")
  ),

  local!values: a!forEach(
    local!splitVals,

    a!localVariables(
      local!usernameAttempted: fv!item[2],  /* represents what the user typed in the login screen - causes complications... */
      local!username: lower(local!usernameAttempted),

      a!map(
        username: local!username,
        usernameAttempted: local!usernameAttempted,
        gmtTimestampString: fv!item[1],
        convertedTimeValue: local(gmt(parsedate(fv!item[1]))),  /* parseDate() is from "date and time utilities" plug-in */
        success: fv!item[3],
        ipAddress: fv!item[4],
        mode: fv!item[5]
      )
    )
  ),
  local!filteredUsernames: {"internal.service.account2", "service.account.3"},  /* list any other API / service accounts here (if any) when they may have a lot of routine hits not relevant for your use of this tool */

  local!filteredValues: a!forEach(
    local!values,
    if(contains(local!filteredUsernames, fv!item.username), {}, fv!item)
  ),

  local!usernameFilter: null(),
  local!successFilter: "either",

  local!filteringActive: or(
    a!isNotNullOrEmpty(local!usernameFilter),
    local!successFilter <> "either"
  ),

  local!reFilteredValues: if(
    not(local!filteringActive),
    local!filteredValues,

    a!flatten(a!forEach(
      local!filteredValues,
      if(
        and(
          or(
            a!isNullOrEmpty(local!usernameFilter),
            find(local!usernameFilter, fv!item.username) > 0
          ),
          or(
            local!successFilter = "either",
            and(
              local!successFilter = "success",
              fv!item.success = "Succeeded"
            ),
            and(
              local!successFilter = "fail",
              fv!item.success = "Failed"
            )
          )
        ),
        fv!item,
        {}
      )
    ))
  ),

  local!pagingInfo: a!pagingInfo(1, 20, a!sortInfo(field: "convertedTimeValue", ascending: false())),

  local!dataSubset: todatasubset(
    arrayToPage: local!reFilteredValues,
    pagingConfiguration: local!pagingInfo
  ),


  /* bending over backwards here to do the (somewhat costly) username-status-checking query (hitting query record type for user record for each line-item) just for the current page, post-filtering, etc. */
  local!updatedDataSubset: a!update(
    local!dataSubset,
    "data",
    a!forEach(
      local!dataSubset.data,
      a!update(
        data: fv!item,
        index: {"actualUsername", "usernameExists", "accountActive"},
        value: {
          a!localVariables(
            local!currentUsername: fv!item.username,
            local!currentUserStatus: rule!MIKE_UTIL_getCaseSensitiveUsernameAccountInfo(username: local!currentUsername),
            local!isActive: and(local!currentUserStatus.exists, a!defaultValue(local!currentUserStatus.isActive, false())),
            {
              local!currentUserStatus.username,
              local!currentUserStatus.exists,
              local!isActive
            }
          )
        }
      )
    )
  ),
  
  local!positiveColor: "#28724F", /* darker green */
  local!warningColor: "#E29262", /* warning color */
  local!negativeColor: "#BA0C2F", /* darker red */
  local!inactiveColor: "#bcbdc0", /* lighter grey */


  a!sectionLayout(
    label: "Login Audit Log Reader",
    contents: {
      a!columnsLayout(
        columns: {
          a!columnLayout(
            contents: {
              a!textField(
                labelPosition: "COLLAPSED",
                placeholder: "(filter by username)",
                value: local!usernameFilter,
                saveInto: local!usernameFilter,
                refreshAfter: "KEYPRESS"
              ),
              a!richTextDisplayField(
                labelPosition: "COLLAPSED",
                value: {
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "refresh",
                        caption: if(local!isTotalCountStale, "Refresh Needed (new info found)", "Refresh"),
                        link: a!dynamicLink(
                          saveInto: {
                            a!save(local!refreshCounter, local!refreshCounter + 1)
                          },
                          showWhen: local!isViewingToday
                        ),
                        linkStyle: "STANDALONE",
                        showWhen: local!isViewingToday,
                        color: if(local!isTotalCountStale, local!warningColor, "ACCENT"),
                        size: if(local!isTotalCountStale, "LARGE", null())
                      ),
                      a!richTextIcon(
                        icon: "refresh",
                        caption: "Currently viewing a previous day's log file, so 'Refresh' is not really relevant here.",
                        showWhen: not(local!isViewingToday),
                        color: local!inactiveColor,
                        size: "SMALL"
                      ),
                      a!richTextItem(
                        text: {
                          "           ",
                          a!richTextIcon(
                            icon: "remove",
                            caption: "Reset Filter(s)",
                            link: {
                              a!dynamicLink(
                                saveInto: {
                                  a!save(local!usernameFilter, ""),
                                  a!save(local!successFilter, "either"),
                                  a!save(local!pagingInfo.startIndex, 1),
                                  a!save(local!refreshCounter, local!refreshCounter + 1)
                                },
                                showWhen: local!filteringActive
                              )
                            },
                            linkStyle: "STANDALONE",
                            color: if(
                              local!filteringActive,
                              "NEGATIVE",
                              local!inactiveColor
                            ),
                            size: "MEDIUM_PLUS"
                          )
                        }
                      )
                    }
                  )
                }
              )
            },
            width: "9X"
          ),
          a!columnLayout(
            contents: {

              a!radioButtonField(
                choiceLabels: {"Success", "Failure", "Both"},
                choiceValues: {"success", "fail", "either"},
                labelPosition: "COLLAPSED",
                value: local!successFilter,
                saveInto: local!successFilter,
                choiceLayout: "COMPACT",
                marginBelow: "MORE"
              ),
              
              a!richTextDisplayField(
                labelPosition: "COLLAPSED",
                value: {
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "backward",
                        caption: "Back 1 Day",
                        color: if(
                          local!hasPreviousDay,
                          "",
                          local!inactiveColor
                        )
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, local!relevantDay - 1),
                        a!save(local!pagingInfo.startIndex, 1)
                      },
                      showWhen: local!hasPreviousDay
                    ),
                    linkStyle: "STANDALONE"
                  ),
                  "  ",
                  a!richTextItem(
                    text: text(local!relevantDay, "yyyy-mm-dd")
                  ),

                  "  ",
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "forward",
                        caption: "Forward 1 Day",
                        color: if(local!isViewingToday, local!inactiveColor, "")
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, local!relevantDay + 1),
                        a!save(local!pagingInfo.startIndex, 1)
                      },
                      showWhen: not(local!isViewingToday)
                    ),
                    linkStyle: "STANDALONE"
                  ),
                  " ",
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "fast-forward",
                        caption: "Jump to Today"
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, today()),
                        a!save(local!pagingInfo.startIndex, 1)
                      }
                    ),
                    linkStyle: "STANDALONE",
                    showWhen: local!relevantDay < today()-1
                  )
                },
                marginAbove: "LESS"
              )
            },
            width: "4X"
          )
        }
      ),

      a!gridField(
        data: local!updatedDataSubset,
        columns: {
          a!gridColumn(
            label: "Username",
            sortField: "username",
            value: a!richTextDisplayField(
              value: {
                a!richTextItem(
                  text: {
                    fv!row.username,
                    " ",
                    a!richTextIcon(
                      icon: "check-circle-o",
                      caption: "Account Active",
                      showWhen: and(fv!row.usernameExists, fv!row.accountActive),
                      color: local!positiveColor,
                      size: "SMALL"
                    ),
                    a!richTextIcon(
                      icon: "remove",
                      caption: "Account Deactivated",
                      showWhen: and(fv!row.usernameExists, not(a!defaultValue(fv!row.accountActive, true()))),
                      color: local!warningColor,
                      size: "STANDARD"
                    ),
                    a!richTextIcon(
                      icon: "question-circle-o",
                      caption: "Unknown Username",
                      showWhen: not(a!defaultValue(fv!row.usernameExists, false())),
                      color: local!negativeColor,
                      size: "SMALL"
                    )
                  }
                )
              }
            )
          ),
          a!gridColumn(
            label: "Timestamp",
            sortField: "convertedTimeValue",
            value: a!richTextDisplayField(
              value: {
                a!richTextItem(
                  text: text(fv!row.convertedTimeValue, "yyyy-mm-dd HH:mm:ss")
                ),
                char(10),
                a!richTextItem(
                  text: "(GMT:  " & fv!row.gmtTimestampString & ")",
                  color: "SECONDARY",
                  size: "SMALL",
                  style: "EMPHASIS"
                )
              }
            )
          ),
          a!gridColumn(
            label: "Success",
            value: fv!row.success
          ),
          a!gridColumn(
            label: "Mode",
            value: fv!row.mode
          ),
          a!gridColumn(
            label: "IP",
            value: fv!row.ipAddress
          )
        },
        pagingSaveInto: local!pagingInfo,
        borderStyle: "STANDARD",
        shadeAlternateRows: true()
      ),

      a!richTextDisplayField(
        label: "PowerBI hits filtered out:",
        labelPosition: "ADJACENT",
        value: local!powerBiCounter
      )
    }
  )
/* by: Mike Schmitt */
)

and here's the "MIKE_UTIL_getCaseSensitiveUsernameAccountInfo()" code:

a!localVariables(
  local!username: a!defaultValue(ri!username, "-=-=-"),
  local!userQuery: a!refreshVariable(
    refreshAlways: true(),
    value: a!queryRecordType(
      recordType: 'recordType!{SYSTEM_RECORD_TYPE_USER}User',
      pagingInfo: a!pagingInfo(1, 1),
      fetchTotalCount: true(),
      fields: {'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username', 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_active}active', 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_uuid}uuid'},
      filters: a!queryLogicalExpression(
        operator: "OR",
        filters: {
          /* doing this to rule out lowercase username first, as it's the normal way we try to initialize them */
          a!queryFilter(
            field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
            operator: "=",
            value: lower(local!username)
          ),
          a!queryFilter(
            field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
            operator: "=",
            value: local!username
          )
        },
        logicalExpressions: {
          a!queryLogicalExpression(
            operator: "AND",
    
            /* these will hopefully require the username to match exactly except for casing */
            filters: {
              a!queryFilter(
                field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
                operator: "starts with",
                value: local!username
              ),
              a!queryFilter(
                field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
                operator: "ends with",
                value: local!username
              )
            }
          )
        }
      )
    )
  ),
  
  if(
    local!userQuery.totalCount = 0,
    a!map(
      exists: false()
    ),
    a!map(
      exists: true(),
      username: tostring(local!userQuery.data['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username']),
      uuid: local!userQuery.data[1]['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_uuid}uuid'],
      isActive: local!userQuery.data[1]['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_active}active']
    )
  )
)

(i'm GUESSING this should copy/paste across systems, but there's a chance you might need to manually fix the record type association)

If there are any questions or suggestions, please let me know!

11 replies

mathieud0001
Genius
May 13, 2025 12:57 AM UTC

Thanks for sharing Mike!

Participating Frequently
May 13, 2025 1:19 AM UTC

Great Mike. Thanks for sharing.

mikes0011
mikes0011Author
Brainy
Aug 4, 2026 6:07 PM UTC
a!localVariables(
local!relevantDay: today(),
local!isViewingToday: local!relevantDay = today(),

local!dayString: if(
local!isViewingToday,
"",
"." & text(local!relevantDay, "yyyy-mm-dd")
),
local!refreshCounter: 0,

/* use this (optionally) to filter out a service account that might get a disproportionate number of hits per day. if none, set it to a nonsense string. */
local!usernameToFilterOut: "api.test.account.name.asdf",

local!currentLog: a!refreshVariable(
value: readcsvlogpagingwithheaders(
csvPath: "login-audit.csv" & local!dayString,
startIndex: 1,
batchSize: 1000,
/* (old headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "secondaryUuid"}, )*/
/* headers updated as of ~25.4 */
headers: {"timestamp", "username", "status", "ip", "source", "agent", "transactionId", "uuid", "mfaUser"},
filterColumName: "username",
filterOperator: "!=",
filterValue: local!usernameToFilterOut
),
refreshOnVarChange: {local!refreshCounter}
),
local!backgroundAutoRefreshedTotalCount: a!refreshVariable(
value: if(
local!isViewingToday,
readcsvlogpagingwithheaders(
csvPath: "login-audit.csv", startIndex: 1, batchSize: 1,
headers: {"timestamp", "username", "status", "ip", "source", "agent", "transactionId", "uuid", "mfaUser"},
filterColumName: "username", filterOperator: "!=", filterValue: local!usernameToFilterOut
).totalCount,
null()
),
refreshInterval: 0.5
),
local!isTotalCountStale: and(
local!isViewingToday,
local!backgroundAutoRefreshedTotalCount <> local!currentLog.totalCount
),

/* using brute force a bit here to tell if any logs exist for any of the past 4 days */
local!hasPreviousDay: or(
readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-1, "yyyy-mm-dd") ).totalCount > -1,
readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-2, "yyyy-mm-dd") ).totalCount > -1,
readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-3, "yyyy-mm-dd") ).totalCount > -1,
readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-4, "yyyy-mm-dd") ).totalCount > -1
),

local!powerBiCounter: a!refreshVariable(
value: readcsvlogpagingwithheaders(
csvPath: "login-audit.csv" & local!dayString,
startIndex: 1,
batchSize: 1,
headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
filterColumName: "username",
filterOperator: "=",
filterValue: local!usernameToFilterOut
).totalCount,
refreshOnVarChange: {local!refreshCounter}
),

local!splitVals: a!forEach(
local!currentLog.rows,
split(fv!item, ",")
),

local!values: a!forEach(
local!splitVals,

a!localVariables(
local!usernameAttempted: fv!item[2], /* represents what the user typed in the login screen - causes complications... */
local!username: lower(local!usernameAttempted),

a!map(
username: local!username,
usernameAttempted: local!usernameAttempted,
gmtTimestampString: fv!item[1],
convertedTimeValue: local(gmt(parsedate(fv!item[1]))), /* parseDate() is from "date and time utilities" plug-in */
success: fv!item[3],
ipAddress: fv!item[4],
mode: fv!item[5]
)
)
),
local!filteredUsernames: {"internal.service.account2", "service.account.3"}, /* list any other API / service accounts here (if any) when they may have a lot of routine hits not relevant for your use of this tool */

local!filteredValues: a!forEach(
local!values,
if(contains(local!filteredUsernames, fv!item.username), {}, fv!item)
),

local!usernameFilter: null(),
local!successFilter: "either",

local!filteringActive: or(
a!isNotNullOrEmpty(local!usernameFilter),
local!successFilter <> "either"
),

local!reFilteredValues: if(
not(local!filteringActive),
local!filteredValues,

a!flatten(a!forEach(
local!filteredValues,
if(
and(
or(
a!isNullOrEmpty(local!usernameFilter),
find(local!usernameFilter, fv!item.username) > 0
),
or(
local!successFilter = "either",
and(
local!successFilter = "success",
fv!item.success = "Succeeded"
),
and(
local!successFilter = "fail",
fv!item.success = "Failed"
)
)
),
fv!item,
{}
)
))
),

local!pagingInfo: a!pagingInfo(1, 20, a!sortInfo(field: "convertedTimeValue", ascending: false())),

local!dataSubset: todatasubset(
arrayToPage: local!reFilteredValues,
pagingConfiguration: local!pagingInfo
),


/* bending over backwards here to do the (somewhat costly) username-status-checking query (hitting query record type for user record for each line-item) just for the current page, post-filtering, etc. */
local!updatedDataSubset: a!update(
local!dataSubset,
"data",
a!forEach(
local!dataSubset.data,
a!update(
data: fv!item,
index: {"actualUsername", "usernameExists", "accountActive"},
value: {
a!localVariables(
local!currentUsername: fv!item.username,
local!currentUserStatus: rule!MIKE_UTIL_getCaseSensitiveUsernameAccountInfo(username: local!currentUsername),
local!isActive: and(local!currentUserStatus.exists, a!defaultValue(local!currentUserStatus.isActive, false())),
{
local!currentUserStatus.username,
local!currentUserStatus.exists,
local!isActive
}
)
}
)
)
),

local!positiveColor: "#28724F", /* darker green */
local!warningColor: "#E29262", /* warning color */
local!negativeColor: "#BA0C2F", /* darker red */
local!inactiveColor: "#bcbdc0", /* lighter grey */


a!sectionLayout(
label: "Login Audit Log Reader",
contents: {
a!columnsLayout(
columns: {
a!columnLayout(
contents: {
a!textField(
labelPosition: "COLLAPSED",
placeholder: "(filter by username)",
value: local!usernameFilter,
saveInto: local!usernameFilter,
refreshAfter: "KEYPRESS"
),
a!richTextDisplayField(
labelPosition: "COLLAPSED",
value: {
a!richTextItem(
text: {
a!richTextIcon(
icon: "refresh",
caption: if(local!isTotalCountStale, "Refresh Needed (new info found)", "Refresh"),
link: a!dynamicLink(
saveInto: {
a!save(local!refreshCounter, local!refreshCounter + 1)
},
showWhen: local!isViewingToday
),
linkStyle: "STANDALONE",
showWhen: local!isViewingToday,
color: if(local!isTotalCountStale, local!warningColor, "ACCENT"),
size: if(local!isTotalCountStale, "LARGE", null())
),
a!richTextIcon(
icon: "refresh",
caption: "Currently viewing a previous day's log file, so 'Refresh' is not really relevant here.",
showWhen: not(local!isViewingToday),
color: local!inactiveColor,
size: "SMALL"
),
a!richTextItem(
text: {
" ",
a!richTextIcon(
icon: "remove",
caption: "Reset Filter(s)",
link: {
a!dynamicLink(
saveInto: {
a!save(local!usernameFilter, ""),
a!save(local!successFilter, "either"),
a!save(local!pagingInfo.startIndex, 1),
a!save(local!refreshCounter, local!refreshCounter + 1)
},
showWhen: local!filteringActive
)
},
linkStyle: "STANDALONE",
color: if(
local!filteringActive,
"NEGATIVE",
local!inactiveColor
),
size: "MEDIUM_PLUS"
)
}
)
}
)
}
)
},
width: "9X"
),
a!columnLayout(
contents: {

a!radioButtonField(
choiceLabels: {"Success", "Failure", "Both"},
choiceValues: {"success", "fail", "either"},
labelPosition: "COLLAPSED",
value: local!successFilter,
saveInto: local!successFilter,
choiceLayout: "COMPACT",
marginBelow: "MORE"
),

a!richTextDisplayField(
labelPosition: "COLLAPSED",
value: {
a!richTextItem(
text: {
a!richTextIcon(
icon: "backward",
caption: "Back 1 Day",
color: if(
local!hasPreviousDay,
"",
local!inactiveColor
)
)
},
link: a!dynamicLink(
saveInto: {
a!save(local!relevantDay, local!relevantDay - 1),
a!save(local!pagingInfo.startIndex, 1)
},
showWhen: local!hasPreviousDay
),
linkStyle: "STANDALONE"
),
" ",
a!richTextItem(
text: text(local!relevantDay, "yyyy-mm-dd")
),

" ",
a!richTextItem(
text: {
a!richTextIcon(
icon: "forward",
caption: "Forward 1 Day",
color: if(local!isViewingToday, local!inactiveColor, "")
)
},
link: a!dynamicLink(
saveInto: {
a!save(local!relevantDay, local!relevantDay + 1),
a!save(local!pagingInfo.startIndex, 1)
},
showWhen: not(local!isViewingToday)
),
linkStyle: "STANDALONE"
),
" ",
a!richTextItem(
text: {
a!richTextIcon(
icon: "fast-forward",
caption: "Jump to Today"
)
},
link: a!dynamicLink(
saveInto: {
a!save(local!relevantDay, today()),
a!save(local!pagingInfo.startIndex, 1)
}
),
linkStyle: "STANDALONE",
showWhen: local!relevantDay < today()-1
)
},
marginAbove: "LESS"
)
},
width: "4X"
)
}
),

a!gridField(
data: local!updatedDataSubset,
pagingControls: "ROW_COUNT",
columns: {
a!gridColumn(
label: "Username",
sortField: "username",
value: a!richTextDisplayField(
value: {
a!richTextItem(
text: {
fv!row.username,
" ",
a!richTextIcon(
icon: "check-circle-o",
caption: "Account Active",
showWhen: and(fv!row.usernameExists, fv!row.accountActive),
color: local!positiveColor,
size: "SMALL"
),
a!richTextIcon(
icon: "remove",
caption: "Account Deactivated",
showWhen: and(fv!row.usernameExists, not(a!defaultValue(fv!row.accountActive, true()))),
color: local!warningColor,
size: "STANDARD"
),
a!richTextIcon(
icon: "question-circle-o",
caption: "Unknown Username",
showWhen: not(a!defaultValue(fv!row.usernameExists, false())),
color: local!negativeColor,
size: "SMALL"
)
}
)
}
)
),
a!gridColumn(
label: "Timestamp",
sortField: "convertedTimeValue",
value: a!richTextDisplayField(
value: {
a!richTextItem(
text: text(fv!row.convertedTimeValue, "yyyy-mm-dd HH:mm:ss")
),
char(10),
a!richTextItem(
text: "(GMT: " & fv!row.gmtTimestampString & ")",
color: "SECONDARY",
size: "SMALL",
style: "EMPHASIS"
)
}
)
),
a!gridColumn(
label: "Success",
value: fv!row.success
),
a!gridColumn(
label: "Mode",
value: fv!row.mode
),
a!gridColumn(
label: "IP",
value: fv!row.ipAddress
)
},
pagingSaveInto: local!pagingInfo,
borderStyle: "STANDARD",
shadeAlternateRows: true()
),

a!richTextDisplayField(
label: "PowerBI hits filtered out:",
labelPosition: "ADJACENT",
value: local!powerBiCounter
)
}
)
/* by: Mike Schmitt */
)

Trying out the new updated Community functionality here and providing a slightly updated version.  This version is especially important as the headers in the Login Audit CSV were updated around ~Appian 25.4 with no other announcement, and it breaks the query without this update.

 

The Expression Guru
kapils024874
Inspiring
Aug 6, 2026 10:36 AM UTC

That’s a great use case ​@mikes0011,
 
But the provided SAIL code block displays the heavy use of readcsvlogpagingwithheaders() and readcsvlog() inside UI local variables (e.g., local!currentLog, local!powerBiCounter, and local!hasPreviousDay).

I have a question as log files grow, won't parsing raw CSVs on-the-fly using readcsvlogpagingwithheaders cause significant performance issues?
To make this scale, shouldn't we be using a scheduled process to ingest these logs into a database table instead of querying flat files directly in the UI?

 

mikes0011
mikes0011Author
Brainy
Aug 6, 2026 12:43 PM UTC

“I have a question as log files grow, won't parsing raw CSVs on-the-fly using readcsvlogpagingwithheaders cause significant performance issues?"

 

How big does your daily login audit file actually grow in a single day?  For mine, even after several hundred logins for the day, it still only takes a few tenths of a second to load the entire interface even with fresh queries.

The Expression Guru
kapils024874
Inspiring
Aug 6, 2026 1:19 PM UTC

Enterprise environments can process thousands of logins, meaning the files can grow large enough to bottleneck live UI parsing even within a single day.  Won’t it ??

 

kapils024874
Inspiring
Aug 6, 2026 1:17 PM UTC

Thank you for the explanation, ​@mikes0011 

Let me know your thoughts on the approach below:
package this UI alongside a simple database table and a scheduled ingestion process to keep it a quick install. The risk in my approach is prioritizing a "no precedents" drop-in over reliable system performance.

Even I don’t have a multi-server environment to implement this and see how it will behave but just dropping the question over here in case someone gives it a shot and can reply back 

What happens in a multi-node environment where readcsvlog only retrieves the partial log from the specific server handling the user's immediate session?

mikes0011
mikes0011Author
Brainy
Aug 6, 2026 1:21 PM UTC

The risk in your approach is prioritizing a "no precedents" drop-in over reliable system performance.

I don’t believe there’s any particular “risk” other than it perhaps not working quite as well as a full-resolved solution 🤷  I personally consider this merely a design trade-off to consider. 

For me it’s more important to have a timely and up-to-date accounting of the current day’s login activities, which a nightly scheduled process would not capture.

The Expression Guru