Got some stats displaying
This commit is contained in:
parent
1f5eae33b9
commit
083f9f3901
@ -186,6 +186,31 @@ ul.menu-list-dropped {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
aside {
|
||||
padding: 0.3em 1em;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Other Styles :D */
|
||||
.success {
|
||||
background: rgb(28, 184, 65); /* this is a green */
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgb(202, 60, 60); /* this is a maroon */
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: rgb(223, 117, 20); /* this is an orange */
|
||||
}
|
||||
.secondary {
|
||||
background: rgb(66, 184, 221); /* this is a light blue*/
|
||||
}
|
||||
.primary {
|
||||
background: #1f8dd6;
|
||||
}
|
||||
|
||||
/* -- Responsive Styles (Media Queries) ------------------------------------- */
|
||||
|
||||
/*
|
||||
|
29014
assets/js/highcharts.js
Normal file
29014
assets/js/highcharts.js
Normal file
File diff suppressed because it is too large
Load Diff
113
assets/js/main_stats.js
Normal file
113
assets/js/main_stats.js
Normal file
@ -0,0 +1,113 @@
|
||||
document.addEventListener("DOMContentLoaded", function(event) {
|
||||
var main_channel_names = ['general','random'],
|
||||
main_member_numbers = [],
|
||||
main_message_numbers = [],
|
||||
other_channel_names = [],
|
||||
other_member_numbers = [],
|
||||
other_message_numbers = [];
|
||||
for(var i = 0; i < stats.channels.length; i++){
|
||||
if(stats.channels[i].name == "general") {
|
||||
main_member_numbers[0] = stats.channels[i].member_count;
|
||||
main_message_numbers[0] = stats.channels[i].message_count;
|
||||
} else if(stats.channels[i].name == "random") {
|
||||
main_member_numbers[1] = stats.channels[i].member_count;
|
||||
main_message_numbers[1] = stats.channels[i].message_count;
|
||||
} else {
|
||||
other_channel_names.push(stats.channels[i].name);
|
||||
other_member_numbers.push(stats.channels[i].member_count);
|
||||
other_message_numbers.push(stats.channels[i].message_count);
|
||||
}
|
||||
}
|
||||
|
||||
var mainChart = new Highcharts.Chart({
|
||||
chart: {
|
||||
renderTo: 'mainStatsBarChart',
|
||||
type: 'column'
|
||||
},
|
||||
|
||||
title: {
|
||||
text: 'Main Channels'
|
||||
},
|
||||
|
||||
yAxis: [{
|
||||
min: 0,
|
||||
title: { text: 'Messages' }
|
||||
},{
|
||||
min: 0,
|
||||
title: { text: 'Members' },
|
||||
opposite: true
|
||||
}],
|
||||
|
||||
plotOptions: {
|
||||
column: {
|
||||
grouping: false,
|
||||
shadow: false,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories: main_channel_names
|
||||
},
|
||||
|
||||
series: [{
|
||||
name: 'Messages',
|
||||
color: 'rgba(126,86,134,0.9)',
|
||||
data: main_message_numbers,
|
||||
pointPadding: 0.3
|
||||
},{
|
||||
name: 'Members',
|
||||
color: 'rgba(165,170,217,1)',
|
||||
data: main_member_numbers,
|
||||
pointPadding: 0.4,
|
||||
yAxis: 1
|
||||
}]
|
||||
});
|
||||
|
||||
var otherChart = new Highcharts.Chart({
|
||||
chart: {
|
||||
renderTo: 'statsBarChart',
|
||||
type: 'column'
|
||||
},
|
||||
|
||||
title: {
|
||||
text: 'Other Channels'
|
||||
},
|
||||
|
||||
yAxis: [{
|
||||
min: 0,
|
||||
title: { text: 'Members' }
|
||||
},{
|
||||
min: 0,
|
||||
title: { text: 'Messages' },
|
||||
opposite: true
|
||||
}],
|
||||
|
||||
tooltip: { shared: true },
|
||||
|
||||
plotOptions: {
|
||||
column: {
|
||||
grouping: false,
|
||||
shadow: false,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories: other_channel_names
|
||||
},
|
||||
|
||||
series: [{
|
||||
name: 'Messages',
|
||||
color: 'rgba(126,86,134,0.9)',
|
||||
data: other_message_numbers,
|
||||
pointPadding: 0.3
|
||||
},{
|
||||
name: 'Members',
|
||||
color: 'rgba(165,170,217,1)',
|
||||
data: other_member_numbers,
|
||||
pointPadding: 0.4,
|
||||
yAxis: 1
|
||||
}]
|
||||
});
|
||||
});
|
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -97,7 +98,7 @@ func (p *generalStatProcessor) GetStatKeys() []string {
|
||||
func (p *generalStatProcessor) ProcessMessage(m *Message) {
|
||||
incrementUserStat(m.User, "message-hour-"+m.Time.Format("15"))
|
||||
incrementUserStat(m.User, "message-dow-"+m.Time.Format("Mon"))
|
||||
incrementUserStat(m.User, "message-dom-"+m.Time.Format("_2"))
|
||||
incrementUserStat(m.User, "message-dom-"+m.Time.Format("02"))
|
||||
}
|
||||
func (p *generalStatProcessor) ProcessBotMessage(m *Message) {}
|
||||
|
||||
@ -111,6 +112,99 @@ func (p *generalStatProcessor) ProcessChannelMessage(m *Message) {
|
||||
|
||||
incrementChannelStat(m.Channel, "message-hour-"+m.Time.Format("15"))
|
||||
incrementChannelStat(m.Channel, "message-dow-"+m.Time.Format("Mon"))
|
||||
incrementChannelStat(m.Channel, "message-dom-"+m.Time.Format("_2"))
|
||||
incrementChannelStat(m.Channel, "message-dom-"+m.Time.Format("02"))
|
||||
}
|
||||
func (p *generalStatProcessor) ProcessBotChannelMessage(m *Message) {}
|
||||
|
||||
/*
|
||||
* Web Site Module
|
||||
*/
|
||||
type generalWebModule struct{}
|
||||
|
||||
func (wm *generalWebModule) GetName() string {
|
||||
return "General Web Module"
|
||||
}
|
||||
func (wm *generalWebModule) GetRoutes() map[string]func(http.ResponseWriter, *http.Request) {
|
||||
ret := make(map[string]func(http.ResponseWriter, *http.Request))
|
||||
ret["/"] = wm.handleStats
|
||||
return ret
|
||||
}
|
||||
func (wm *generalWebModule) Register() {
|
||||
for k, v := range wm.GetRoutes() {
|
||||
r.HandleFunc(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (wm *generalWebModule) GetMenuEntries() []menuItem {
|
||||
var ret []menuItem
|
||||
ret = append(ret, menuItem{Text: "Stats", Link: "/"})
|
||||
return ret
|
||||
}
|
||||
|
||||
func (wm *generalWebModule) GetBottomMenuEntries() []menuItem {
|
||||
var ret []menuItem
|
||||
return ret
|
||||
}
|
||||
|
||||
func (wm *generalWebModule) handleStats(w http.ResponseWriter, req *http.Request) {
|
||||
initRequest(w, req)
|
||||
|
||||
type ChannelStat struct {
|
||||
Name string
|
||||
MemberCount int
|
||||
MessageCount int
|
||||
}
|
||||
type StatData struct {
|
||||
TotalChannelMessages int
|
||||
TotalChannels int
|
||||
ChannelStats []ChannelStat
|
||||
Error string
|
||||
}
|
||||
|
||||
// Get the global stats
|
||||
var s StatData
|
||||
var err error
|
||||
|
||||
openDatabase()
|
||||
chanlst := getChannelList()
|
||||
var chanstats []ChannelStat
|
||||
for _, k := range chanlst {
|
||||
chanstats = append(chanstats, ChannelStat{
|
||||
Name: getChannelName(k),
|
||||
MemberCount: getChannelMemberCount(k),
|
||||
MessageCount: getChannelMessageCount(k),
|
||||
})
|
||||
}
|
||||
s.ChannelStats = chanstats
|
||||
s.TotalChannelMessages = getTotalChannelMsgCount()
|
||||
s.TotalChannels = len(chanlst)
|
||||
closeDatabase()
|
||||
|
||||
sc := "var stats = {totalchannelmessages:"
|
||||
sc = fmt.Sprintf("%s%d,", sc, s.TotalChannelMessages)
|
||||
sc = sc + "\"channels\":["
|
||||
for _, k := range s.ChannelStats {
|
||||
sc = fmt.Sprintf("%s{name:\"%s\",member_count:%d,message_count:%d},",
|
||||
sc,
|
||||
k.Name,
|
||||
k.MemberCount,
|
||||
k.MessageCount,
|
||||
)
|
||||
}
|
||||
// Trim the last ,
|
||||
sc = sc[:len(sc)-1]
|
||||
sc = sc + "]"
|
||||
sc = sc + "};"
|
||||
|
||||
addToInlineScript(sc)
|
||||
|
||||
site.Scripts = append(site.Scripts, "/assets/js/main_stats.js")
|
||||
|
||||
if err != nil {
|
||||
setFlashMessage("Error Counting Channel Messages", "error")
|
||||
}
|
||||
site.TemplateData = s
|
||||
|
||||
setMenuItemActive("Stats")
|
||||
showPage("stats.html", site, w)
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
type levelUpStatProcessor struct{}
|
||||
|
||||
func (p *levelUpStatProcessor) GetName() string {
|
||||
@ -39,3 +41,36 @@ func (p *levelUpStatProcessor) ProcessChannelMessage(m *Message) {
|
||||
}
|
||||
func (p *levelUpStatProcessor) ProcessAdminChannelMessage(m *Message) {}
|
||||
func (p *levelUpStatProcessor) ProcessBotChannelMessage(m *Message) {}
|
||||
|
||||
/*
|
||||
* Web Site Module
|
||||
*/
|
||||
type levelUpWebModule struct{}
|
||||
|
||||
func (wm *levelUpWebModule) GetName() string {
|
||||
return "LevelUp Web Module"
|
||||
}
|
||||
func (wm *levelUpWebModule) GetRoutes() map[string]func(http.ResponseWriter, *http.Request) {
|
||||
ret := make(map[string]func(http.ResponseWriter, *http.Request))
|
||||
//ret["/levelup/"] = wm.
|
||||
return ret
|
||||
}
|
||||
func (wm *levelUpWebModule) Register() {
|
||||
for k, v := range wm.GetRoutes() {
|
||||
r.HandleFunc(k, v)
|
||||
}
|
||||
}
|
||||
func (wm *levelUpWebModule) GetMenuEntries() []menuItem {
|
||||
var ret []menuItem
|
||||
ret = append(ret, menuItem{Text: "LevelUp!", Link: "/levelup/"})
|
||||
return ret
|
||||
}
|
||||
func (wm *levelUpWebModule) GetBottomMenuEntries() []menuItem {
|
||||
var ret []menuItem
|
||||
return ret
|
||||
}
|
||||
|
||||
func (wm *levelUpWebModule) handleLevelUpGeneral(w http.ResponseWriter, req *http.Request) {
|
||||
initRequest(w, req)
|
||||
|
||||
}
|
||||
|
@ -55,7 +55,6 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// For now, we're not running the web server
|
||||
statWebMain(slack)
|
||||
statBotMain(slack)
|
||||
}
|
||||
|
105
statbot_model.go
105
statbot_model.go
@ -398,6 +398,7 @@ func addChannelStat(channel string, key string, addVal int) error {
|
||||
openDatabase()
|
||||
v, err := getChannelStat(channel, key)
|
||||
err = saveChannelStat(channel, key, v+addVal)
|
||||
v, err = getChannelStat(channel, key)
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
@ -410,6 +411,55 @@ func decrementChannelStat(channel string, key string) error {
|
||||
return addChannelStat(channel, key, -1)
|
||||
}
|
||||
|
||||
func getChannelName(chnl string) string {
|
||||
var ret string
|
||||
openDatabase()
|
||||
db.View(func(tx *bolt.Tx) error {
|
||||
var b, chB, chIB *bolt.Bucket
|
||||
var err error
|
||||
|
||||
b = tx.Bucket([]byte("channels"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("Error opening 'channels' bucket")
|
||||
}
|
||||
if chB = b.Bucket([]byte(chnl)); chB == nil {
|
||||
return fmt.Errorf("Error opening channel bucket (%s)", chnl)
|
||||
}
|
||||
if chIB = chB.Bucket([]byte("info")); chIB == nil {
|
||||
return fmt.Errorf("Error opening channel info bucket (%s/info)", chnl)
|
||||
}
|
||||
ret, err = bktGetString(chIB, "name")
|
||||
return err
|
||||
})
|
||||
closeDatabase()
|
||||
return ret
|
||||
}
|
||||
|
||||
func getChannelMemberCount(chnl string) int {
|
||||
var ret int
|
||||
openDatabase()
|
||||
db.View(func(tx *bolt.Tx) error {
|
||||
var b, chB, chIB *bolt.Bucket
|
||||
|
||||
b = tx.Bucket([]byte("channels"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("Error opening 'channels' bucket")
|
||||
}
|
||||
if chB = b.Bucket([]byte(chnl)); chB == nil {
|
||||
return fmt.Errorf("Error opening channel bucket (%s)", chnl)
|
||||
}
|
||||
if chIB = chB.Bucket([]byte("info")); chIB == nil {
|
||||
return fmt.Errorf("Error opening channel info bucket (%s/info)", chnl)
|
||||
}
|
||||
// Get all of the members into a []string
|
||||
chMembersB := chIB.Bucket([]byte("members"))
|
||||
ret = chMembersB.Stats().KeyN
|
||||
return nil
|
||||
})
|
||||
closeDatabase()
|
||||
return ret
|
||||
}
|
||||
|
||||
func getChannelStat(channel string, key string) (int, error) {
|
||||
openDatabase()
|
||||
var ret int
|
||||
@ -421,8 +471,8 @@ func getChannelStat(channel string, key string) (int, error) {
|
||||
if b == nil {
|
||||
return fmt.Errorf("Unable to open 'channels' bucket")
|
||||
}
|
||||
if chB, err = b.CreateBucketIfNotExists([]byte(channel)); err == nil {
|
||||
if chSB, err = chB.CreateBucketIfNotExists([]byte("stats")); err == nil {
|
||||
if chB = b.Bucket([]byte(channel)); chB != nil {
|
||||
if chSB = chB.Bucket([]byte("stats")); chSB != nil {
|
||||
ret, err = bktGetInt(chSB, key)
|
||||
return err
|
||||
}
|
||||
@ -671,8 +721,8 @@ func getUserStat(user string, key string) (int, error) {
|
||||
if b == nil {
|
||||
return fmt.Errorf("Unable to open 'users' bucket")
|
||||
}
|
||||
if uB, err = b.CreateBucketIfNotExists([]byte(user)); err == nil {
|
||||
if uSB, err = uB.CreateBucketIfNotExists([]byte("stats")); err == nil {
|
||||
if uB = b.Bucket([]byte(user)); uB != nil {
|
||||
if uSB = uB.Bucket([]byte("stats")); uSB != nil {
|
||||
ret, err = bktGetInt(uSB, key)
|
||||
return err
|
||||
}
|
||||
@ -705,6 +755,7 @@ func addUserStat(user string, key string, addVal int) error {
|
||||
openDatabase()
|
||||
v, err := getUserStat(user, key)
|
||||
err = saveUserStat(user, key, v+addVal)
|
||||
v, err = getUserStat(user, key)
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
@ -777,6 +828,52 @@ func getAllUsersStats() (map[string]int, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func getChannelMessageCount(channel string) int {
|
||||
var ret int
|
||||
openDatabase()
|
||||
db.View(func(tx *bolt.Tx) error {
|
||||
var b, chB, chMB *bolt.Bucket
|
||||
var err error
|
||||
|
||||
b = tx.Bucket([]byte("channels"))
|
||||
if chB = b.Bucket([]byte(channel)); chB != nil {
|
||||
if chMB = chB.Bucket([]byte("messages")); chMB != nil {
|
||||
ret = chMB.Stats().BucketN
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
closeDatabase()
|
||||
return ret
|
||||
}
|
||||
|
||||
func getChannelList() []string {
|
||||
openDatabase()
|
||||
// First build channel list
|
||||
var chans []string
|
||||
db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("channels"))
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
chans = append(chans, string(k))
|
||||
return nil
|
||||
})
|
||||
})
|
||||
closeDatabase()
|
||||
return chans
|
||||
}
|
||||
|
||||
func getTotalChannelMsgCount() int {
|
||||
var ret int
|
||||
openDatabase()
|
||||
// First build channel list
|
||||
chans := getChannelList()
|
||||
for _, k := range chans {
|
||||
ret = ret + getChannelMessageCount(k)
|
||||
}
|
||||
closeDatabase()
|
||||
return ret
|
||||
}
|
||||
|
||||
func bktGetBucket(b *bolt.Bucket, key string) (*bolt.Bucket, error) {
|
||||
bkt := b.Bucket([]byte(key))
|
||||
if bkt != nil {
|
||||
|
@ -19,8 +19,9 @@ type SiteData struct {
|
||||
Port int
|
||||
SessionName string
|
||||
|
||||
Stylesheets []string
|
||||
Scripts []string
|
||||
Stylesheets []string
|
||||
Scripts []string
|
||||
InlineScript string
|
||||
|
||||
Flash flashMessage // Quick message at top of page
|
||||
Menu []menuItem // Top-aligned menu items
|
||||
@ -46,6 +47,16 @@ var site SiteData
|
||||
var sessionStore = sessions.NewCookieStore([]byte("gostatbot secret cookie nobody will guess"))
|
||||
var r *mux.Router
|
||||
|
||||
type webModule interface {
|
||||
GetName() string
|
||||
GetRoutes() map[string]func(http.ResponseWriter, *http.Request)
|
||||
Register()
|
||||
GetMenuEntries() []menuItem
|
||||
GetBottomMenuEntries() []menuItem
|
||||
}
|
||||
|
||||
var webModules []webModule
|
||||
|
||||
// This is the main function for the web server
|
||||
func statWebMain(slack *Slack) {
|
||||
site.Title = "stat_bot"
|
||||
@ -58,7 +69,9 @@ func statWebMain(slack *Slack) {
|
||||
|
||||
assetHandler := http.FileServer(http.Dir("./assets/"))
|
||||
http.Handle("/assets/", http.StripPrefix("/assets/", assetHandler))
|
||||
r.HandleFunc("/", handleStats)
|
||||
|
||||
registerWebModule(new(generalWebModule))
|
||||
|
||||
http.Handle("/", r)
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", site.Port), context.ClearHandler(http.DefaultServeMux)))
|
||||
@ -73,33 +86,39 @@ func initRequest(w http.ResponseWriter, req *http.Request) {
|
||||
site.Stylesheets = append(site.Stylesheets, "https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css")
|
||||
|
||||
site.Scripts = make([]string, 0, 0)
|
||||
site.Scripts = append(site.Scripts, "/assets/js/highcharts.js")
|
||||
|
||||
site.Menu = make([]menuItem, 0, 0)
|
||||
site.Menu = append(site.Menu, menuItem{Text: "Stats", Link: "/stats/"})
|
||||
|
||||
site.BottomMenu = make([]menuItem, 0, 0)
|
||||
site.BottomMenu = append(site.BottomMenu, menuItem{Text: "Admin", Link: "/admin/"})
|
||||
for _, mod := range webModules {
|
||||
for _, m := range mod.GetMenuEntries() {
|
||||
site.Menu = append(site.Menu, m)
|
||||
}
|
||||
for _, m := range mod.GetBottomMenuEntries() {
|
||||
site.BottomMenu = append(site.BottomMenu, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleStats(w http.ResponseWriter, req *http.Request) {
|
||||
initRequest(w, req)
|
||||
|
||||
type StatData struct {
|
||||
TotalChannelMessages int
|
||||
Error string
|
||||
func registerWebModule(b webModule) {
|
||||
// Register a web module
|
||||
// Make sure that we haven't already registered it (or another that has the same route)
|
||||
for _, mod := range webModules {
|
||||
if mod.GetName() == b.GetName() {
|
||||
panic(fmt.Errorf("Attempted to Re-register Web Module %s", b.GetName()))
|
||||
} else {
|
||||
for k := range mod.GetRoutes() {
|
||||
for nk := range b.GetRoutes() {
|
||||
if k == nk {
|
||||
panic(fmt.Errorf("Attempted to Re-register Web Route %s", k))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the global stats
|
||||
var s StatData
|
||||
if stats, err := getAllUsersStats(); err == nil {
|
||||
s = StatData{TotalChannelMessages: stats["channel-message"], Error: ""}
|
||||
} else {
|
||||
s = StatData{TotalChannelMessages: -1, Error: fmt.Sprintf("%s", err)}
|
||||
}
|
||||
site.TemplateData = s
|
||||
|
||||
setMenuItemActive("Stats")
|
||||
showPage("stats.html", site, w)
|
||||
b.Register()
|
||||
webModules = append(webModules, b)
|
||||
}
|
||||
|
||||
// showPage
|
||||
@ -159,6 +178,14 @@ func getSessionStringValue(key string, w http.ResponseWriter, req *http.Request)
|
||||
return retVal, nil
|
||||
}
|
||||
|
||||
func setFlashMessage(msg string, stat string) {
|
||||
site.Flash = flashMessage{Message: msg, Status: stat}
|
||||
}
|
||||
|
||||
func addToInlineScript(s string) {
|
||||
site.InlineScript = fmt.Sprintf("%s%s", site.InlineScript, s)
|
||||
}
|
||||
|
||||
func assertError(err error, w http.ResponseWriter) bool {
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
|
@ -3,5 +3,6 @@
|
||||
{{ range $i, $v := .Scripts }}
|
||||
<script src="{{ $v }}"></script>
|
||||
{{ end }}
|
||||
<script>{{ .InlineScript }}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,21 +1,6 @@
|
||||
<div>
|
||||
devICT Slack!
|
||||
</div>
|
||||
<div>
|
||||
{{ .TemplateData.Error }}
|
||||
</div>
|
||||
<div>
|
||||
<table class="pure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">Statistics</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Total Channel Messages</td>
|
||||
<td>{{ .TemplateData.TotalChannelMessages }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="mainStatsBarChart"></div>
|
||||
<hr />
|
||||
<div id="statsBarChart"></div>
|
||||
|
Loading…
Reference in New Issue
Block a user