Connection status/error messages

This commit is contained in:
Leo Vasanko 2023-11-07 18:01:34 +00:00
parent 54d6ea6332
commit d5e1304c0d
10 changed files with 124 additions and 75 deletions

View File

@ -3,9 +3,10 @@
:root { :root {
--primary-color: #000; --primary-color: #000;
--primary-background: #ddd; --primary-background: #ddd;
--header-background: #246; --header-background: var(--soft-color);
--header-color: #ccc; --header-color: #ccc;
--primary-color: #000; --primary-color: #000;
--soft-color: #146;
--accent-color: #f80; --accent-color: #f80;
--transition-time: 0.2s; --transition-time: 0.2s;
/* The following are overridden by responsive layouts */ /* The following are overridden by responsive layouts */
@ -16,7 +17,7 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--primary-color: #ddd; --primary-color: #ddd;
--primary-background: #003; --primary-background: var(--soft-color);
--header-background: #000; --header-background: #000;
--header-color: #ccc; --header-color: #ccc;
} }
@ -235,3 +236,9 @@ main {
opacity: 0; opacity: 0;
} }
} }
.error-message {
padding: .5em;
font-weight: bold;
background: var(--accent-color);
color: #000;
}

View File

@ -129,8 +129,8 @@
</td> </td>
</tr> </tr>
</template> </template>
<tr class="summary"> <tr class="summary" v-if="props.documents.length > 1">
<td colspan="3" class="right">{{props.documents.length}} items shown:</td> <td colspan="3" class="right">{{props.documents.length}} items</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td> <td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
@ -140,7 +140,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, onBeforeUpdate } from 'vue' import { ref, computed, watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document' import type { Document } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@ -510,4 +510,7 @@ tbody .selection input {
.loc { .loc {
color: #888; color: #888;
} }
.summary {
color: #888;
}
</style> </style>

View File

@ -1,6 +1,10 @@
<template> <template>
<nav class="headermain"> <nav class="headermain">
<div class="buttons"> <div class="buttons">
<template v-if="documentStore.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton /> <UploadButton />
<SvgButton <SvgButton
name="create-folder" name="create-folder"
@ -23,17 +27,6 @@
<SvgButton name="cog" @click="settingsMenu" /> <SvgButton name="cog" @click="settingsMenu" />
</div> </div>
</nav> </nav>
<context-menu v-model:show="showMenu">
<context-menu-item label="Simple item" @click="onMenuClick(1)" />
<context-menu-sperator /><!--use this to add sperator-->
<context-menu-group label="Menu with child">
<context-menu-item label="Item1" @click="onMenuClick(2)" />
<context-menu-item label="Item2" @click="onMenuClick(3)" />
<context-menu-group label="Child with v-for 50">
<context-menu-item v-for="index of 50" :key="index" :label="'Item3-'+index" @click="onLoopMenuClick(index)" />
</context-menu-group>
</context-menu-group>
</context-menu>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -64,12 +57,16 @@ const toggleSearchInput = () => {
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = []
if (documentStore.user.isLoggedIn) {
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
} else {
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
}
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom, x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
items: [ items,
{ label: "Logout", onClick: () => { documentStore.logout() } },
]
}) })
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required"> <ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
<form @submit.prevent="login"> <form @submit.prevent="login">
<div class="login-container"> <div class="login-container">
<label for="username">Username:</label> <label for="username">Username:</label>
@ -7,6 +7,8 @@
id="username" id="username"
name="username" name="username"
autocomplete="username" autocomplete="username"
spellcheck="false"
autocorrect="off"
required required
v-model="loginForm.username" v-model="loginForm.username"
/> />
@ -16,12 +18,14 @@
name="password" name="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
spellcheck="false"
autocorrect="off"
required required
v-model="loginForm.password" v-model="loginForm.password"
/> />
</div> </div>
<h3 v-if="loginForm.error.length > 0" class="error-text"> <h3 class="error-text">
{{ loginForm.error }} {{ loginForm.error || '\u00A0' }}
</h3> </h3>
<div class="dialog-buttons"> <div class="dialog-buttons">
<div class="spacer"></div> <div class="spacer"></div>
@ -33,21 +37,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { loginUser, logoutUser } from '@/repositories/User' import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { useDocumentStore } from '@/stores/documents' import { useDocumentStore } from '@/stores/documents'
const confirmLoading = ref<boolean>(false) const confirmLoading = ref<boolean>(false)
const store = useDocumentStore() const store = useDocumentStore()
const logout = async () => {
try {
await logoutUser()
} finally {
location.reload()
}
}
const loginForm = reactive({ const loginForm = reactive({
username: '', username: '',
password: '', password: '',
@ -59,13 +55,10 @@ const login = async () => {
loginForm.error = '' loginForm.error = ''
confirmLoading.value = true confirmLoading.value = true
const msg = await loginUser(loginForm.username, loginForm.password) const msg = await loginUser(loginForm.username, loginForm.password)
console.log('Logged in', msg) store.login(msg.data.username, !!msg.data.privileged)
store.login(msg.username, !!msg.privileged)
} catch (error) { } catch (error) {
const httpError = error as ISimpleError const httpError = error as ISimpleError
if (httpError.name) { loginForm.error = httpError.message || '🛑 Unknown error'
loginForm.error = httpError.message
}
} finally { } finally {
confirmLoading.value = false confirmLoading.value = false
} }
@ -87,18 +80,22 @@ const login = async () => {
align-items: center; align-items: center;
} }
.button-login { .button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer; cursor: pointer;
font-weight: bold;
border: 0; border: 0;
border-radius: .5rem; border-radius: .5rem;
padding: .5rem; padding: .5rem 2rem;
margin-left: auto; margin-left: auto;
background-color: var(--accent-color); transition: all var(--transition-time) linear;
color: var(--primary-color);
} }
.ant-btn-primary:not(:disabled):hover { .button-login:hover, .button-login:focus {
background-color: var(--blue-color); background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
} }
.error-text { .error-text {
color: var(--red-color); color: var(--red-color);
height: 1em;
} }
</style> </style>

View File

@ -23,15 +23,18 @@ const props = withDefaults(
title: '' title: ''
} }
) )
const show = () => {
onMounted(() => {
dialog.value!.showModal() dialog.value!.showModal()
}
defineExpose({ show })
onMounted(() => {
show()
}) })
</script> </script>
<style> <style>
/* Style for the background */ /* Style for the background */
body:has(dialog[open])::before { dialog::backdrop {
content: ''; content: '';
display: block; display: block;
position: fixed; position: fixed;
@ -40,7 +43,7 @@ body:has(dialog[open])::before {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #0008; background: #0008;
backdrop-filter: blur(0.2em); backdrop-filter: blur(0.4em);
z-index: 1000; z-index: 1000;
} }
@ -50,6 +53,7 @@ dialog[open] {
color: black; color: black;
display: block; display: block;
border: none; border: none;
font-size: 1.2rem;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000; box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem; padding: 1rem;
@ -58,11 +62,13 @@ dialog[open] {
left: 0; left: 0;
z-index: 1001; z-index: 1001;
} }
input {
font: inherit;
}
dialog[open] > h1 { dialog[open] > h1 {
background: var(--accent-color); background: var(--soft-color);
color: black; color: #fff;
font-size: 1rem; font-size: 1.2rem;
margin: -1rem -1rem 0 -1rem; margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem; padding: 0.5rem 1rem 0.5rem 1rem;
} }

View File

@ -12,7 +12,7 @@ class ClientClass {
try { try {
msg = await res.json() msg = await res.json()
} catch (e) { } catch (e) {
throw new SimpleError(res.status, `HTTP ${res.status} ${res.statusText}`) throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
} }
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message) if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg return msg

View File

@ -15,41 +15,21 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
return webSocket return webSocket
} }
export const watchConnect = async () => { export const watchConnect = () => {
wsWatch = connect(watchUrl, { if (watchTimeout !== null) {
open() { console.log("Connected to", watchUrl)}, clearTimeout(watchTimeout)
message: handleWatchMessage, watchTimeout = null
close: watchReconnect,
})
await wsWatch
} }
export const watchDisconnect = () => {
if (!wsWatch) return
wsWatch.close()
wsWatch = null
}
const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore() const store = useDocumentStore()
if (store.connected) { if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.warn("Disconnected from server", event) console.log(store.error)
store.connected = false
}
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
setTimeout(() => {
wsWatch = connect(watchUrl, { wsWatch = connect(watchUrl, {
message: handleWatchMessage, message: handleWatchMessage,
close: watchReconnect, close: watchReconnect,
}) })
console.log("Attempting to reconnect...") wsWatch.addEventListener("message", event => {
}, reconnectDuration) if (store.connected) return
}
const handleWatchMessage = (event: MessageEvent) => {
const store = useDocumentStore()
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if ('error' in msg) { if ('error' in msg) {
if (msg.error.code === 401) { if (msg.error.code === 401) {
@ -58,7 +38,44 @@ const handleWatchMessage = (event: MessageEvent) => {
} else { } else {
store.error = msg.error.message store.error = msg.error.message
} }
return
} }
if ("server" in msg) {
console.log('Connected to backend', msg)
store.connected = true
reconnectDuration = 500
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
}
})
}
export const watchDisconnect = () => {
if (!wsWatch) return
wsWatch.close()
wsWatch = null
}
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore()
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
store.error = 'Reconnecting...'
}
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
if (watchTimeout !== null) clearTimeout(watchTimeout)
watchTimeout = setTimeout(watchConnect, reconnectDuration)
}
const handleWatchMessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data)
switch (true) { switch (true) {
case !!msg.root: case !!msg.root:
handleRootMessage(msg) handleRootMessage(msg)
@ -79,9 +96,6 @@ const handleWatchMessage = (event: MessageEvent) => {
function handleRootMessage({ root }: { root: DirEntry }) { function handleRootMessage({ root }: { root: DirEntry }) {
const store = useDocumentStore() const store = useDocumentStore()
console.log('Watch root', root) console.log('Watch root', root)
reconnectDuration = 500
store.connected = true
store.user.isLoggedIn = true
store.updateRoot(root) store.updateRoot(root)
tree = root tree = root
} }

View File

@ -8,6 +8,8 @@ import type {
import { formatSize, formatUnixDate, haystackFormat } from '@/utils' import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { collator } from '@/utils' import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData } type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = { type DirectoryData = {
@ -108,10 +110,14 @@ export const useDocumentStore = defineStore({
this.user.privileged = privileged this.user.privileged = privileged
this.user.isLoggedIn = true this.user.isLoggedIn = true
this.user.isOpenLoginModal = false this.user.isOpenLoginModal = false
if (!this.connected) watchConnect()
},
loginDialog() {
this.user.isOpenLoginModal = true
}, },
async logout() { async logout() {
const res = await fetch('/logout', { method: 'POST' }) console.log("Logout")
if (!res.ok) throw Error(`Logout failed: ${res.statusText}`) await logoutUser()
this.$reset() this.$reset()
history.go() // Reload page history.go() // Reload page
} }

View File

@ -4,7 +4,7 @@ import typing
import msgspec import msgspec
from sanic import Blueprint from sanic import Blueprint
from cista import watching from cista import __version__, config, watching
from cista.fileio import FileServer from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper from cista.util.apphelpers import asend, websocket_wrapper
@ -83,6 +83,23 @@ async def control(req, ws):
@bp.websocket("watch") @bp.websocket("watch")
@websocket_wrapper @websocket_wrapper
async def watch(req, ws): async def watch(req, ws):
await ws.send(
msgspec.json.encode(
{
"server": {
"name": "Cista", # Should be configurable
"version": __version__,
"public": config.config.public,
},
"user": {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
if req.ctx.user
else None,
}
).decode()
)
try: try:
with watching.tree_lock: with watching.tree_lock:
q = watching.pubsub[ws] = asyncio.Queue() q = watching.pubsub[ws] = asyncio.Queue()

View File

@ -38,8 +38,10 @@ async def main_stop(app, loop):
async def use_session(req): async def use_session(req):
req.ctx.session = session.get(req) req.ctx.session = session.get(req)
try: try:
req.ctx.username = req.ctx.session["username"]
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
except (AttributeError, KeyError, TypeError): except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None req.ctx.user = None
# CSRF protection # CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket": if req.method == "GET" and req.headers.upgrade != "websocket":