Compare commits

...

3 Commits

Author SHA1 Message Date
zhengyi d267e169fc 🚧Refactor Compose.vue 2024-03-31 00:11:35 +08:00
zhengyi 484dfa1143 🚧Refactor DashboardHome Done 2024-03-30 22:23:31 +08:00
zhengyi f56331e400 🚧Refactoring... 2024-03-30 21:22:55 +08:00
16 changed files with 2204 additions and 1701 deletions

12
common/types.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Stack {
name?: string;
composeYAML?: string;
composeENV?: string;
isManagedByDockge?: boolean;
endpoint?: string;
composeFileName?: string;
}
export interface JsonConfig {
services?: Record<string, object>;
}

View File

@ -2,8 +2,5 @@
<router-view />
</template>
<script>
export default {
};
<script setup>
</script>

View File

@ -4,7 +4,7 @@
<div class="header-top">
<!-- TODO -->
<button v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
{{ $t("Select") }}
{{ t("Select") }}
</button>
<div class="placeholder"></div>
@ -23,7 +23,7 @@
<!-- TODO -->
<div v-if="false" class="header-filter">
<!-- <StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
<!--<StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
</div>
<!-- TODO: Selection Controls -->
@ -34,17 +34,17 @@
type="checkbox"
/>
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ t("Pause") }}</button>
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ t("Resume") }}</button>
<span v-if="selectedStackCount > 0">
{{ $t("selectedStackCount", [ selectedStackCount ]) }}
{{ t("selectedStackCount", [ selectedStackCount ]) }}
</span>
</div>
</div>
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
<div v-if="Object.keys(sortedStackList).length === 0" class="text-center mt-3">
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
<router-link to="/compose">{{ t("addFirstStackMsg") }}</router-link>
</div>
<StackListItem
@ -59,294 +59,290 @@
</div>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseStackMsg") }}
<Confirm ref="confirmPause" :yes-text="t('Yes')" :no-text="t('No')" @yes="pauseSelected">
{{ t("pauseStackMsg") }}
</Confirm>
</template>
<script>
<script setup lang="ts">
import Confirm from "../components/Confirm.vue";
import StackListItem from "../components/StackListItem.vue";
import { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from "../../../common/util-common";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useSocket } from "../sockets";
import { useI18n } from "vue-i18n";
export default {
components: {
Confirm,
StackListItem,
},
props: {
/** Should the scrollbar be shown */
scrollbar: {
type: Boolean,
},
},
data() {
const socket = useSocket();
const { t } = useI18n();
defineProps<{scrollbar: boolean}>();
const confirmPause = ref<typeof Confirm>();
const searchText = ref<string>("");
const selectMode = ref<boolean>(false);
const selectAll = ref<boolean>(false);
const disableSelectAllWatcher = ref<boolean>(false);
const selectedStacks = ref({});
const windowTop = ref<number>(0);
const filterState = ref ({
status: null,
active: null,
tags: null,
});
/**
* Improve the sticky appearance of the list by increasing its
* height as user scrolls down.
* Not used on mobile.
* @returns {object} Style for stack list
*/
const boxStyle = computed(() => {
if (window.innerWidth > 550) {
return {
searchText: "",
selectMode: false,
selectAll: false,
disableSelectAllWatcher: false,
selectedStacks: {},
windowTop: 0,
filterState: {
status: null,
active: null,
tags: null,
}
height: `calc(100vh - 160px + ${windowTop.value}px)`,
};
},
computed: {
/**
* Improve the sticky appearance of the list by increasing its
* height as user scrolls down.
* Not used on mobile.
* @returns {object} Style for stack list
*/
boxStyle() {
if (window.innerWidth > 550) {
return {
height: `calc(100vh - 160px + ${this.windowTop}px)`,
};
} else {
return {
height: "calc(100vh - 160px)",
};
}
} else {
return {
height: "calc(100vh - 160px)",
};
}
},
});
/**
* Returns a sorted list of stacks based on the applied filters and search text.
* @returns {Array} The sorted list of stacks.
*/
sortedStackList() {
let result = Object.values(this.$root.completeStackList);
/**
* Returns a sorted list of stacks based on the applied filters and search text.
* @returns {Array} The sorted list of stacks.
*/
const sortedStackList = computed(() => {
let result = Object.values(socket.completeStackList.value);
result = result.filter(stack => {
// filter by search text
// finds stack name, tag name or tag value
let searchTextMatch = true;
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
searchTextMatch =
stack.name.toLowerCase().includes(loweredSearchText)
|| stack.tags.find(tag => tag.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
}
// filter by active
let activeMatch = true;
if (this.filterState.active != null && this.filterState.active.length > 0) {
activeMatch = this.filterState.active.includes(stack.active);
}
// filter by tags
let tagsMatch = true;
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
.length > 0;
}
return searchTextMatch && activeMatch && tagsMatch;
});
result.sort((m1, m2) => {
// sort by managed by dockge
if (m1.isManagedByDockge && !m2.isManagedByDockge) {
return -1;
} else if (!m1.isManagedByDockge && m2.isManagedByDockge) {
return 1;
}
// sort by status
if (m1.status !== m2.status) {
if (m2.status === RUNNING) {
return 1;
} else if (m1.status === RUNNING) {
return -1;
} else if (m2.status === EXITED) {
return 1;
} else if (m1.status === EXITED) {
return -1;
} else if (m2.status === CREATED_STACK) {
return 1;
} else if (m1.status === CREATED_STACK) {
return -1;
} else if (m2.status === CREATED_FILE) {
return 1;
} else if (m1.status === CREATED_FILE) {
return -1;
} else if (m2.status === UNKNOWN) {
return 1;
} else if (m1.status === UNKNOWN) {
return -1;
}
}
return m1.name.localeCompare(m2.name);
});
return result;
},
isDarkTheme() {
return document.body.classList.contains("dark");
},
stackListStyle() {
//let listHeaderHeight = 107;
let listHeaderHeight = 60;
if (this.selectMode) {
listHeaderHeight += 42;
}
return {
"height": `calc(100% - ${listHeaderHeight}px)`
};
},
selectedStackCount() {
return Object.keys(this.selectedStacks).length;
},
/**
* Determines if any filters are active.
* @returns {boolean} True if any filter is active, false otherwise.
*/
filtersActive() {
return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== "";
result = result.filter(stack => {
// filter by search text
// finds stack name, tag name or tag value
let searchTextMatch = true;
if (searchText.value !== "") {
const loweredSearchText = searchText.value.toLowerCase();
searchTextMatch =
stack.name.toLowerCase().includes(loweredSearchText)
|| stack.tags.find(tag => tag.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
}
},
watch: {
searchText() {
for (let stack of this.sortedStackList) {
if (!this.selectedStacks[stack.id]) {
if (this.selectAll) {
this.disableSelectAllWatcher = true;
this.selectAll = false;
}
break;
}
}
},
selectAll() {
if (!this.disableSelectAllWatcher) {
this.selectedStacks = {};
if (this.selectAll) {
this.sortedStackList.forEach((item) => {
this.selectedStacks[item.id] = true;
});
}
} else {
this.disableSelectAllWatcher = false;
}
},
selectMode() {
if (!this.selectMode) {
this.selectAll = false;
this.selectedStacks = {};
}
},
},
mounted() {
window.addEventListener("scroll", this.onScroll);
},
beforeUnmount() {
window.removeEventListener("scroll", this.onScroll);
},
methods: {
/**
* Handle user scroll
* @returns {void}
*/
onScroll() {
if (window.top.scrollY <= 133) {
this.windowTop = window.top.scrollY;
} else {
this.windowTop = 133;
}
},
// filter by active
let activeMatch = true;
if (filterState.value.active != null && filterState.value.active.length > 0) {
activeMatch = filterState.value.active.includes(stack.active);
}
/**
* Clear the search bar
* @returns {void}
*/
clearSearchText() {
this.searchText = "";
},
/**
* Update the StackList Filter
* @param {object} newFilter Object with new filter
* @returns {void}
*/
updateFilter(newFilter) {
this.filterState = newFilter;
},
/**
* Deselect a stack
* @param {number} id ID of stack
* @returns {void}
*/
deselect(id) {
delete this.selectedStacks[id];
},
/**
* Select a stack
* @param {number} id ID of stack
* @returns {void}
*/
select(id) {
this.selectedStacks[id] = true;
},
/**
* Determine if stack is selected
* @param {number} id ID of stack
* @returns {bool} Is the stack selected?
*/
isSelected(id) {
return id in this.selectedStacks;
},
/**
* Disable select mode and reset selection
* @returns {void}
*/
cancelSelectMode() {
this.selectMode = false;
this.selectedStacks = {};
},
/**
* Show dialog to confirm pause
* @returns {void}
*/
pauseDialog() {
this.$refs.confirmPause.show();
},
/**
* Pause each selected stack
* @returns {void}
*/
pauseSelected() {
Object.keys(this.selectedStacks)
.filter(id => this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("pauseStack", id, () => {}));
// filter by tags
let tagsMatch = true;
if (filterState.value.tags != null && filterState.value.tags.length > 0) {
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(stackTagId => filterState.value.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
.length > 0;
}
this.cancelSelectMode();
},
/**
* Resume each selected stack
* @returns {void}
*/
resumeSelected() {
Object.keys(this.selectedStacks)
.filter(id => !this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("resumeStack", id, () => {}));
return searchTextMatch && activeMatch && tagsMatch;
});
this.cancelSelectMode();
},
},
result.sort((m1, m2) => {
// sort by managed by dockge
if (m1.isManagedByDockge && !m2.isManagedByDockge) {
return -1;
} else if (!m1.isManagedByDockge && m2.isManagedByDockge) {
return 1;
}
// sort by status
if (m1.status !== m2.status) {
if (m2.status === RUNNING) {
return 1;
} else if (m1.status === RUNNING) {
return -1;
} else if (m2.status === EXITED) {
return 1;
} else if (m1.status === EXITED) {
return -1;
} else if (m2.status === CREATED_STACK) {
return 1;
} else if (m1.status === CREATED_STACK) {
return -1;
} else if (m2.status === CREATED_FILE) {
return 1;
} else if (m1.status === CREATED_FILE) {
return -1;
} else if (m2.status === UNKNOWN) {
return 1;
} else if (m1.status === UNKNOWN) {
return -1;
}
}
return m1.name.localeCompare(m2.name);
});
return result;
});
const isDarkTheme = computed(() => {
return document.body.classList.contains("dark");
});
const stackListStyle = computed(() => {
//let listHeaderHeight = 107;
let listHeaderHeight = 60;
if (selectMode.value) {
listHeaderHeight += 42;
}
return {
"height": `calc(100% - ${listHeaderHeight}px)`
};
});
const selectedStackCount = computed(() => {
return Object.keys(selectedStacks.value).length;
});
/**
* Determines if any filters are active.
* @returns {boolean} True if any filter is active, false otherwise.
*/
const filtersActive = computed(() => {
return filterState.value.status != null || filterState.value.active != null
|| filterState.value.tags != null || searchText.value !== "";
});
watch(searchText, () => {
for (let stack of sortedStackList.value) {
if (!selectedStacks.value[stack.id]) {
if (selectAll.value) {
disableSelectAllWatcher.value = true;
selectAll.value = false;
}
break;
}
}
});
watch(selectAll, () => {
if (!disableSelectAllWatcher.value) {
selectedStacks.value = {};
if (selectAll.value) {
sortedStackList.value.forEach((item) => {
selectedStacks.value[item.id] = true;
});
}
} else {
disableSelectAllWatcher.value = false;
}
});
watch(selectMode, () => {
if (!selectMode.value) {
selectAll.value = false;
selectedStacks.value = {};
}
});
onMounted(() => {
window.addEventListener("scroll", onScroll);
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll);
});
/**
* Handle user scroll
* @returns {void}
*/
const onScroll = () => {
if (window.top.scrollY <= 133) {
windowTop.value = window.top.scrollY;
} else {
windowTop.value = 133;
}
};
/**
* Clear the search bar
*/
const clearSearchText = () => {
searchText.value = "";
};
/**
* Update the StackList Filter
* @param {object} newFilter Object with new filter
*/
const updateFilter = (newFilter: object) => {
filterState.value = newFilter;
};
/**
* Deselect a stack
* @param {number} id ID of stack
* @returns {void}
*/
const deselect = (id: number) => {
delete selectedStacks.value[id];
};
/**
* Select a stack
* @param {number} id ID of stack
*/
const select = (id: number) => {
selectedStacks.value[id] = true;
};
/**
* Determine if stack is selected
* @param {number} id ID of stack
* @returns {bool} Is the stack selected?
*/
const isSelected = (id: number): boolean => {
return id in selectedStacks.value;
};
/**
* Disable select mode and reset selection
* @returns {void}
*/
const cancelSelectMode = (): void => {
selectMode.value = false;
selectedStacks.value = {};
};
/**
* Show dialog to confirm pause
* @returns {void}
*/
const pauseDialog = (): void => {
confirmPause.value?.show();
};
/**
* Pause each selected stack
* @returns {void}
*/
const pauseSelected = (): void => {
Object.keys(selectedStacks.value)
.filter(id => socket.stackList.value[id].active)
.forEach(id => socket.getSocket().emit("pauseStack", id, () => {}));
cancelSelectMode();
};
/**
* Resume each selected stack
* @returns {void}
*/
const resumeSelected = (): void => {
Object.keys(selectedStacks.value)
.filter(id => !socket.stackList.value[id].active)
.forEach(id => socket.getSocket().emit("resumeStack", id, () => {}));
cancelSelectMode();
};
</script>

View File

@ -1,6 +1,10 @@
// @ts-ignore Performance issue when using "vue-i18n", so we use "vue-i18n/dist/vue-i18n.esm-browser.prod.js", but typescript doesn't like that.
import { createI18n } from "vue-i18n/dist/vue-i18n.esm-browser.prod.js";
import en from "./lang/en.json";
import { setPageLocale } from "./util-frontend";
import { useI18n } from "vue-i18n";
const langModules = import.meta.glob("./lang/*.json");
const languageList = {
"bg-BG": "Български",
@ -56,9 +60,19 @@ export const localeDirection = () => {
};
export const i18n = createI18n({
legacy: false,
locale: currentLocale(),
fallbackLocale: "en",
silentFallbackWarn: true,
silentTranslationWarn: true,
messages: messages,
});
export const changeLanguage = async (lang: string) => {
const i18n = useI18n();
const message = (await langModules["./lang/" + lang + ".json"]()).default;
i18n.setLocaleMessage(lang, message);
i18n.locale.value = lang;
localStorage.locale = lang;
setPageLocale();
};

View File

@ -1,42 +1,42 @@
<template>
<div :class="classes">
<div v-if="! $root.socketIO.connected && ! $root.socketIO.firstConnect" class="lost-connection">
<div v-if="! socket.socketIO.value.connected && ! socket.socketIO.value.firstConnect" class="lost-connection">
<div class="container-fluid">
{{ $root.socketIO.connectionErrorMsg }}
<div v-if="$root.socketIO.showReverseProxyGuide">
{{ $t("reverseProxyMsg1") }} <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" target="_blank">{{ $t("reverseProxyMsg2") }}</a>
{{ socket.socketIO.value.connectionErrorMsg }}
<div v-if="socket.socketIO.value.showReverseProxyGuide">
{{ t("reverseProxyMsg1") }} <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" target="_blank">{{ t("reverseProxyMsg2") }}</a>
</div>
</div>
</div>
<!-- Desktop header -->
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<header v-if="true || ! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<router-link to="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title">Dockge</span>
</router-link>
<a v-if="hasNewVersion" target="_blank" href="https://github.com/louislam/dockge/releases" class="btn btn-warning me-3">
<font-awesome-icon icon="arrow-alt-circle-up" /> {{ $t("newUpdate") }}
<font-awesome-icon icon="arrow-alt-circle-up" /> {{ t("newUpdate") }}
</a>
<ul class="nav nav-pills">
<li v-if="$root.loggedIn" class="nav-item me-2">
<li v-if="socket.loggedIn" class="nav-item me-2">
<router-link to="/" class="nav-link">
<font-awesome-icon icon="home" /> {{ $t("home") }}
<font-awesome-icon icon="home" /> {{ t("home") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item me-2">
<li v-if="socket.loggedIn" class="nav-item me-2">
<router-link to="/console" class="nav-link">
<font-awesome-icon icon="terminal" /> {{ $t("console") }}
<font-awesome-icon icon="terminal" /> {{ t("console") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item">
<li v-if="socket.loggedIn" class="nav-item">
<div class="dropdown dropdown-profile-pic">
<div class="nav-link" data-bs-toggle="dropdown">
<div class="profile-pic">{{ $root.usernameFirstChar }}</div>
<div class="profile-pic">{{ socket.usernameFirstChar }}</div>
<font-awesome-icon icon="angle-down" />
</div>
@ -44,10 +44,10 @@
<ul class="dropdown-menu">
<!-- Username -->
<li>
<i18n-t v-if="$root.username != null" tag="span" keypath="signedInDisp" class="dropdown-item-text">
<strong>{{ $root.username }}</strong>
<i18n-t v-if="socket.username != null" tag="span" keypath="signedInDisp" class="dropdown-item-text">
<strong>{{ socket.username }}</strong>
</i18n-t>
<span v-if="$root.username == null" class="dropdown-item-text">{{ $t("signedInDispDisabled") }}</span>
<span v-if="socket.username == null" class="dropdown-item-text">{{ t("signedInDispDisabled") }}</span>
</li>
<li><hr class="dropdown-divider"></li>
@ -56,26 +56,26 @@
<!--<li>
<router-link to="/registry" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="warehouse" /> {{ $t("registry") }}
<font-awesome-icon icon="warehouse" /> {{ t("registry") }}
</router-link>
</li>-->
<li>
<button class="dropdown-item" @click="scanFolder">
<font-awesome-icon icon="arrows-rotate" /> {{ $t("scanFolder") }}
<font-awesome-icon icon="arrows-rotate" /> {{ t("scanFolder") }}
</button>
</li>
<li>
<router-link to="/settings/general" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
<font-awesome-icon icon="cog" /> {{ t("Settings") }}
</router-link>
</li>
<li>
<button class="dropdown-item" @click="$root.logout">
<button class="dropdown-item" @click="socket.logout">
<font-awesome-icon icon="sign-out-alt" />
{{ $t("Logout") }}
{{ t("Logout") }}
</button>
</li>
</ul>
@ -85,74 +85,53 @@
</header>
<main>
<div v-if="$root.socketIO.connecting" class="container mt-5">
<h4>{{ $t("connecting...") }}</h4>
<div v-if="socket.socketIO.value.connecting" class="container mt-5">
<h4>{{ t("connecting...") }}</h4>
</div>
<router-view v-if="$root.loggedIn" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
<router-view v-if="socket.loggedIn" />
<Login v-if="! socket.loggedIn && socket.allowLoginDialog" />
</main>
</div>
</template>
<script>
<script setup>
import Login from "../components/Login.vue";
import { useSocket } from "../sockets";
import { useI18n } from "vue-i18n";
import { computed, onMounted } from "vue";
import { compareVersions } from "compare-versions";
import { toastRes } from "../toast";
import { useTheme } from "../theme";
import { ALL_ENDPOINTS } from "../../../common/util-common";
const socket = useSocket();
const theme = useTheme();
const { t } = useI18n();
export default {
components: {
Login,
},
data() {
return {
};
},
computed: {
// Theme or Mobile
classes() {
const classes = {};
classes[this.$root.theme] = true;
classes["mobile"] = this.$root.isMobile;
return classes;
},
hasNewVersion() {
if (this.$root.info.latestVersion && this.$root.info.version) {
return compareVersions(this.$root.info.latestVersion, this.$root.info.version) >= 1;
} else {
return false;
}
},
},
watch: {
},
mounted() {
},
beforeUnmount() {
},
methods: {
scanFolder() {
this.$root.emitAgent(ALL_ENDPOINTS, "requestStackList", (res) => {
this.$root.toastRes(res);
});
},
},
// Theme or Mobile
const classes = computed(() => {
const classes = {};
classes[theme.theme.value] = true;
// classes["mobile"] = this.$root.isMobile;
return classes;
});
const hasNewVersion = computed(() => {
if (socket.info.value.latestVersion && socket.info.value.version) {
return compareVersions(socket.info.value.latestVersion, socket.info.value.version) >= 1;
} else {
return false;
}
});
const scanFolder = () => {
socket.emitAgent(ALL_ENDPOINTS, "requestStackList", (res) => {
toastRes(res);
});
};
onMounted(() => {
console.log(socket.socketIO.value);
});
</script>
<style lang="scss" scoped>

View File

@ -1,15 +1,15 @@
// Dayjs init inside this, so it has to be the first import
import "../../common/util-common";
import { createApp, defineComponent, h } from "vue";
import { createApp, defineComponent, h, provide, ref, watch } from "vue";
import App from "./App.vue";
import { router } from "./router";
import { FontAwesomeIcon } from "./icon.js";
import { i18n } from "./i18n";
import { changeLanguage, currentLocale, i18n } from "./i18n";
// Dependencies
import "bootstrap";
import Toast, { POSITION, useToast } from "vue-toastification";
import Toast, { POSITION } from "vue-toastification";
import "@xterm/xterm/lib/xterm.js";
// CSS
@ -19,9 +19,10 @@ import "@xterm/xterm/css/xterm.css";
import "./styles/main.scss";
// Minxins
import socket from "./mixins/socket";
import lang from "./mixins/lang";
import theme from "./mixins/theme";
import { useI18n } from "vue-i18n";
import { setPageLocale } from "./util-frontend";
import { SocketPlugin, useSocket } from "./sockets";
import {theme, THEME_INJECT_KEY} from "./theme";
// Set Title
document.title = document.title + " - " + location.host;
@ -33,6 +34,7 @@ app.use(Toast, {
showCloseButtonOnHover: true,
});
app.use(router);
app.use(SocketPlugin);
app.use(i18n);
app.component("FontAwesomeIcon", FontAwesomeIcon);
app.mount("#app");
@ -41,64 +43,23 @@ app.mount("#app");
* Root Vue component
*/
function rootApp() {
const toast = useToast();
return defineComponent({
mixins: [
socket,
lang,
theme,
],
data() {
return {
loggedIn: false,
allowLoginDialog: false,
username: null,
};
},
computed: {
setup() {
const { t } = useI18n();
const loggedIn = ref(false);
const allowLoginDialog = ref(false);
const username = ref(null);
// Initialize Locale
const locale = ref(currentLocale());
changeLanguage(locale.value);
provide("locale", locale);
watch(locale, (val) => {
changeLanguage(locale.value);
});
// Declare Theme
provide(THEME_INJECT_KEY, theme());
},
methods: {
/**
* Show success or error toast dependant on response status code
* @param {object} res Response object
* @returns {void}
*/
toastRes(res) {
let msg = res.msg;
if (res.msgi18n) {
if (msg != null && typeof msg === "object") {
msg = this.$t(msg.key, msg.values);
} else {
msg = this.$t(msg);
}
}
if (res.ok) {
toast.success(msg);
} else {
toast.error(msg);
}
},
/**
* Show a success toast
* @param {string} msg Message to show
* @returns {void}
*/
toastSuccess(msg : string) {
toast.success(this.$t(msg));
},
/**
* Show an error toast
* @param {string} msg Message to show
* @returns {void}
*/
toastError(msg : string) {
toast.error(this.$t(msg));
},
return { t };
},
render: () => h(App),
});

View File

@ -1,6 +1,7 @@
import { currentLocale } from "../i18n";
import { setPageLocale } from "../util-frontend";
import { defineComponent } from "vue";
import {defineComponent, onBeforeMount, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
const langModules = import.meta.glob("../lang/*.json");
export default defineComponent({

View File

@ -1,416 +1,436 @@
import { io } from "socket.io-client";
import { Socket } from "socket.io-client";
import { defineComponent } from "vue";
import jwtDecode from "jwt-decode";
import { Terminal } from "@xterm/xterm";
import { AgentSocket } from "../../../common/agent-socket";
let socket : Socket;
let terminalMap : Map<string, Terminal> = new Map();
export default defineComponent({
data() {
return {
socketIO: {
token: null,
firstConnect: true,
connected: false,
connectCount: 0,
initedSocketIO: false,
connectionErrorMsg: `${this.$t("Cannot connect to the socket server.")} ${this.$t("Reconnecting...")}`,
showReverseProxyGuide: true,
connecting: false,
},
info: {
},
remember: (localStorage.remember !== "0"),
loggedIn: false,
allowLoginDialog: false,
username: null,
composeTemplate: "",
stackList: {},
// All stack list from all agents
allAgentStackList: {} as Record<string, object>,
// online / offline / connecting
agentStatusList: {
},
// Agent List
agentList: {
},
};
},
computed: {
agentCount() {
return Object.keys(this.agentList).length;
},
completeStackList() {
let list : Record<string, object> = {};
for (let stackName in this.stackList) {
list[stackName + "_"] = this.stackList[stackName];
}
for (let endpoint in this.allAgentStackList) {
let instance = this.allAgentStackList[endpoint];
for (let stackName in instance.stackList) {
list[stackName + "_" + endpoint] = instance.stackList[stackName];
}
}
return list;
},
usernameFirstChar() {
if (typeof this.username == "string" && this.username.length >= 1) {
return this.username.charAt(0).toUpperCase();
} else {
return "🐬";
}
},
/**
* Frontend Version
* It should be compiled to a static value while building the frontend.
* Please see ./frontend/vite.config.ts, it is defined via vite.js
* @returns {string}
*/
frontendVersion() {
// eslint-disable-next-line no-undef
return FRONTEND_VERSION;
},
/**
* Are both frontend and backend in the same version?
* @returns {boolean}
*/
isFrontendBackendVersionMatched() {
if (!this.info.version) {
return true;
}
return this.info.version === this.frontendVersion;
},
},
watch: {
"socketIO.connected"() {
if (this.socketIO.connected) {
this.agentStatusList[""] = "online";
} else {
this.agentStatusList[""] = "offline";
}
},
remember() {
localStorage.remember = (this.remember) ? "1" : "0";
},
// Reload the SPA if the server version is changed.
"info.version"(to, from) {
if (from && from !== to) {
window.location.reload();
}
},
},
created() {
this.initSocketIO();
},
mounted() {
return;
},
methods: {
endpointDisplayFunction(endpoint : string) {
if (endpoint) {
return endpoint;
} else {
return this.$t("currentEndpoint");
}
},
/**
* Initialize connection to socket server
* @param bypass Should the check for if we
* are on a status page be bypassed?
*/
initSocketIO(bypass = false) {
// No need to re-init
if (this.socketIO.initedSocketIO) {
return;
}
this.socketIO.initedSocketIO = true;
let url : string;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
url = location.protocol + "//" + location.hostname + ":5001";
} else {
url = location.protocol + "//" + location.host;
}
let connectingMsgTimeout = setTimeout(() => {
this.socketIO.connecting = true;
}, 1500);
socket = io(url);
// Handling events from agents
let agentSocket = new AgentSocket();
socket.on("agent", (eventName : unknown, ...args : unknown[]) => {
agentSocket.call(eventName, ...args);
});
socket.on("connect", () => {
console.log("Connected to the socket server");
clearTimeout(connectingMsgTimeout);
this.socketIO.connecting = false;
this.socketIO.connectCount++;
this.socketIO.connected = true;
this.socketIO.showReverseProxyGuide = false;
const token = this.storage().token;
if (token) {
if (token !== "autoLogin") {
console.log("Logging in by token");
this.loginByToken(token);
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! this.loggedIn) {
this.allowLoginDialog = true;
this.storage().removeItem("token");
}
}, 5000);
}
} else {
this.allowLoginDialog = true;
}
this.socketIO.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect");
this.socketIO.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socketIO.connected = false;
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.socketIO.connectionErrorMsg = `${this.$t("Cannot connect to the socket server.")} [${err}] ${this.$t("reconnecting...")}`;
this.socketIO.showReverseProxyGuide = true;
this.socketIO.connected = false;
this.socketIO.firstConnect = false;
this.socketIO.connecting = false;
});
// Custom Events
socket.on("info", (info) => {
this.info = info;
});
socket.on("autoLogin", () => {
this.loggedIn = true;
this.storage().token = "autoLogin";
this.socketIO.token = "autoLogin";
this.allowLoginDialog = false;
this.afterLogin();
});
socket.on("setup", () => {
console.log("setup");
this.$router.push("/setup");
});
agentSocket.on("terminalWrite", (terminalName, data) => {
const terminal = terminalMap.get(terminalName);
if (!terminal) {
//console.error("Terminal not found: " + terminalName);
return;
}
terminal.write(data);
});
agentSocket.on("stackList", (res) => {
if (res.ok) {
if (!res.endpoint) {
this.stackList = res.stackList;
} else {
if (!this.allAgentStackList[res.endpoint]) {
this.allAgentStackList[res.endpoint] = {
stackList: {},
};
}
this.allAgentStackList[res.endpoint].stackList = res.stackList;
}
}
});
socket.on("stackStatusList", (res) => {
if (res.ok) {
for (let stackName in res.stackStatusList) {
const stackObj = this.stackList[stackName];
if (stackObj) {
stackObj.status = res.stackStatusList[stackName];
}
}
}
});
socket.on("agentStatus", (res) => {
this.agentStatusList[res.endpoint] = res.status;
if (res.msg) {
this.toastError(res.msg);
}
});
socket.on("agentList", (res) => {
console.log(res);
if (res.ok) {
this.agentList = res.agentList;
}
});
socket.on("refresh", () => {
location.reload();
});
},
/**
* The storage currently in use
* @returns Current storage
*/
storage() : Storage {
return (this.remember) ? localStorage : sessionStorage;
},
getSocket() : Socket {
return socket;
},
emitAgent(endpoint : string, eventName : string, ...args : unknown[]) {
this.getSocket().emit("agent", endpoint, eventName, ...args);
},
/**
* Get payload of JWT cookie
* @returns {(object | undefined)} JWT payload
*/
getJWTPayload() {
const jwtToken = this.storage().token;
if (jwtToken && jwtToken !== "autoLogin") {
return jwtDecode(jwtToken);
}
return undefined;
},
/**
* Send request to log user in
* @param {string} username Username to log in with
* @param {string} password Password to log in with
* @param {string} token User token
* @param {loginCB} callback Callback to call with result
* @returns {void}
*/
login(username : string, password : string, token : string, callback) {
this.getSocket().emit("login", {
username,
password,
token,
}, (res) => {
if (res.tokenRequired) {
callback(res);
}
if (res.ok) {
this.storage().token = res.token;
this.socketIO.token = res.token;
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}
callback(res);
});
},
/**
* Log in using a token
* @param {string} token Token to log in with
* @returns {void}
*/
loginByToken(token : string) {
socket.emit("loginByToken", token, (res) => {
this.allowLoginDialog = true;
if (! res.ok) {
this.logout();
} else {
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
}
});
},
/**
* Log out of the web application
* @returns {void}
*/
logout() {
socket.emit("logout", () => { });
this.storage().removeItem("token");
this.socketIO.token = null;
this.loggedIn = false;
this.username = null;
this.clearData();
},
/**
* @returns {void}
*/
clearData() {
},
afterLogin() {
},
bindTerminal(endpoint : string, terminalName : string, terminal : Terminal) {
// Load terminal, get terminal screen
this.emitAgent(endpoint, "terminalJoin", terminalName, (res) => {
if (res.ok) {
terminal.write(res.buffer);
terminalMap.set(terminalName, terminal);
} else {
this.toastRes(res);
}
});
},
unbindTerminal(terminalName : string) {
terminalMap.delete(terminalName);
},
}
});
// import { io } from "socket.io-client";
// import { Socket } from "socket.io-client";
// import {defineComponent, ref} from "vue";
// import jwtDecode from "jwt-decode";
// import { Terminal } from "@xterm/xterm";
// import { AgentSocket } from "../../../common/agent-socket";
//
// let socket : Socket;
//
// let terminalMap : Map<string, Terminal> = new Map();
//
// export default defineComponent({
// data() {
// return {
// socketIO: {
// token: null,
// firstConnect: true,
// connected: false,
// connectCount: 0,
// initedSocketIO: false,
// connectionErrorMsg: `${this.$t("Cannot connect to the socket server.")} ${this.$t("Reconnecting...")}`,
// showReverseProxyGuide: true,
// connecting: false,
// },
// info: {
//
// },
// remember: (localStorage.remember !== "0"),
// loggedIn: false,
// allowLoginDialog: false,
// username: null,
// composeTemplate: "",
//
// stackList: {},
//
// // All stack list from all agents
// allAgentStackList: {} as Record<string, object>,
//
// // online / offline / connecting
// agentStatusList: {
//
// },
//
// // Agent List
// agentList: {
//
// },
// };
// },
// computed: {
//
// agentCount() {
// return Object.keys(this.agentList).length;
// },
//
// completeStackList() {
// let list : Record<string, object> = {};
//
// for (let stackName in this.stackList) {
// list[stackName + "_"] = this.stackList[stackName];
// }
//
// for (let endpoint in this.allAgentStackList) {
// let instance = this.allAgentStackList[endpoint];
// for (let stackName in instance.stackList) {
// list[stackName + "_" + endpoint] = instance.stackList[stackName];
// }
// }
// return list;
// },
//
// usernameFirstChar() {
// if (typeof this.username == "string" && this.username.length >= 1) {
// return this.username.charAt(0).toUpperCase();
// } else {
// return "🐬";
// }
// },
//
// /**
// * Frontend Version
// * It should be compiled to a static value while building the frontend.
// * Please see ./frontend/vite.config.ts, it is defined via vite.js
// * @returns {string}
// */
// frontendVersion() {
// // eslint-disable-next-line no-undef
// return FRONTEND_VERSION;
// },
//
// /**
// * Are both frontend and backend in the same version?
// * @returns {boolean}
// */
// isFrontendBackendVersionMatched() {
// if (!this.info.version) {
// return true;
// }
// return this.info.version === this.frontendVersion;
// },
//
// },
// watch: {
//
// "socketIO.connected"() {
// if (this.socketIO.connected) {
// this.agentStatusList[""] = "online";
// } else {
// this.agentStatusList[""] = "offline";
// }
// },
//
// remember() {
// localStorage.remember = (this.remember) ? "1" : "0";
// },
//
// // Reload the SPA if the server version is changed.
// "info.version"(to, from) {
// if (from && from !== to) {
// window.location.reload();
// }
// },
// },
// created() {
// this.initSocketIO();
// },
// mounted() {
// return;
//
// },
// methods: {
//
// endpointDisplayFunction(endpoint : string) {
// if (endpoint) {
// return endpoint;
// } else {
// return this.$t("currentEndpoint");
// }
// },
//
// /**
// * Initialize connection to socket server
// * @param bypass Should the check for if we
// * are on a status page be bypassed?
// */
// initSocketIO(bypass = false) {
// console.log("INIT");
// // No need to re-init
// if (this.socketIO.initedSocketIO) {
// return;
// }
//
// this.socketIO.initedSocketIO = true;
// let url : string;
// const env = process.env.NODE_ENV || "production";
// if (env === "development" || localStorage.dev === "dev") {
// url = location.protocol + "//" + location.hostname + ":5001";
// } else {
// url = location.protocol + "//" + location.host;
// }
//
// let connectingMsgTimeout = setTimeout(() => {
// this.socketIO.connecting = true;
// }, 1500);
//
// socket = io(url);
//
// // Handling events from agents
// let agentSocket = new AgentSocket();
// socket.on("agent", (eventName : unknown, ...args : unknown[]) => {
// agentSocket.call(eventName, ...args);
// });
//
// socket.on("connect", () => {
// console.log("Connected to the socket server");
//
// clearTimeout(connectingMsgTimeout);
// this.socketIO.connecting = false;
//
// this.socketIO.connectCount++;
// this.socketIO.connected = true;
// this.socketIO.showReverseProxyGuide = false;
// const token = this.storage().token;
//
// if (token) {
// if (token !== "autoLogin") {
// console.log("Logging in by token");
// this.loginByToken(token);
// } else {
// // Timeout if it is not actually auto login
// setTimeout(() => {
// if (! this.loggedIn) {
// this.allowLoginDialog = true;
// this.storage().removeItem("token");
// }
// }, 5000);
// }
// } else {
// this.allowLoginDialog = true;
// }
//
// this.socketIO.firstConnect = false;
// });
//
// socket.on("disconnect", () => {
// console.log("disconnect");
// this.socketIO.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
// this.socketIO.connected = false;
// });
//
// socket.on("connect_error", (err) => {
// console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
// this.socketIO.connectionErrorMsg = `${this.$t("Cannot connect to the socket server.")} [${err}] ${this.$t("reconnecting...")}`;
// this.socketIO.showReverseProxyGuide = true;
// this.socketIO.connected = false;
// this.socketIO.firstConnect = false;
// this.socketIO.connecting = false;
// });
//
// // Custom Events
//
// socket.on("info", (info) => {
// this.info = info;
// });
//
// socket.on("autoLogin", () => {
// this.loggedIn = true;
// this.storage().token = "autoLogin";
// this.socketIO.token = "autoLogin";
// this.allowLoginDialog = false;
// this.afterLogin();
// });
//
// socket.on("setup", () => {
// console.log("setup");
// this.$router.push("/setup");
// });
//
// agentSocket.on("terminalWrite", (terminalName, data) => {
// const terminal = terminalMap.get(terminalName);
// if (!terminal) {
// //console.error("Terminal not found: " + terminalName);
// return;
// }
// terminal.write(data);
// });
//
// agentSocket.on("stackList", (res) => {
// if (res.ok) {
// if (!res.endpoint) {
// this.stackList = res.stackList;
// } else {
// if (!this.allAgentStackList[res.endpoint]) {
// this.allAgentStackList[res.endpoint] = {
// stackList: {},
// };
// }
// this.allAgentStackList[res.endpoint].stackList = res.stackList;
// }
// }
// });
//
// socket.on("stackStatusList", (res) => {
// if (res.ok) {
// for (let stackName in res.stackStatusList) {
// const stackObj = this.stackList[stackName];
// if (stackObj) {
// stackObj.status = res.stackStatusList[stackName];
// }
// }
// }
// });
//
// socket.on("agentStatus", (res) => {
// this.agentStatusList[res.endpoint] = res.status;
//
// if (res.msg) {
// this.toastError(res.msg);
// }
// });
//
// socket.on("agentList", (res) => {
// console.log(res);
// if (res.ok) {
// this.agentList = res.agentList;
// }
// });
//
// socket.on("refresh", () => {
// location.reload();
// });
// },
//
// /**
// * The storage currently in use
// * @returns Current storage
// */
// storage() : Storage {
// return (this.remember) ? localStorage : sessionStorage;
// },
//
// getSocket() : Socket {
// return socket;
// },
//
// emitAgent(endpoint : string, eventName : string, ...args : unknown[]) {
// this.getSocket().emit("agent", endpoint, eventName, ...args);
// },
//
// /**
// * Get payload of JWT cookie
// * @returns {(object | undefined)} JWT payload
// */
// getJWTPayload() {
// const jwtToken = this.storage().token;
//
// if (jwtToken && jwtToken !== "autoLogin") {
// return jwtDecode(jwtToken);
// }
// return undefined;
// },
//
// /**
// * Send request to log user in
// * @param {string} username Username to log in with
// * @param {string} password Password to log in with
// * @param {string} token User token
// * @param {loginCB} callback Callback to call with result
// * @returns {void}
// */
// login(username : string, password : string, token : string, callback) {
// this.getSocket().emit("login", {
// username,
// password,
// token,
// }, (res) => {
// if (res.tokenRequired) {
// callback(res);
// }
//
// if (res.ok) {
// this.storage().token = res.token;
// this.socketIO.token = res.token;
// this.loggedIn = true;
// this.username = this.getJWTPayload()?.username;
//
// this.afterLogin();
//
// // Trigger Chrome Save Password
// history.pushState({}, "");
// }
//
// callback(res);
// });
// },
//
// /**
// * Log in using a token
// * @param {string} token Token to log in with
// * @returns {void}
// */
// loginByToken(token : string) {
// socket.emit("loginByToken", token, (res) => {
// this.allowLoginDialog = true;
//
// if (! res.ok) {
// this.logout();
// } else {
// this.loggedIn = true;
// this.username = this.getJWTPayload()?.username;
// this.afterLogin();
// }
// });
// },
//
// /**
// * Log out of the web application
// * @returns {void}
// */
// logout() {
// socket.emit("logout", () => { });
// this.storage().removeItem("token");
// this.socketIO.token = null;
// this.loggedIn = false;
// this.username = null;
// this.clearData();
// },
//
// /**
// * @returns {void}
// */
// clearData() {
//
// },
//
// afterLogin() {
//
// },
//
// bindTerminal(endpoint : string, terminalName : string, terminal : Terminal) {
// // Load terminal, get terminal screen
// this.emitAgent(endpoint, "terminalJoin", terminalName, (res) => {
// if (res.ok) {
// terminal.write(res.buffer);
// terminalMap.set(terminalName, terminal);
// } else {
// this.toastRes(res);
// }
// });
// },
//
// unbindTerminal(terminalName : string) {
// terminalMap.delete(terminalName);
// },
//
// }
// });
//
// export function useSocket() {
// const socketIO = ref({
// token: null,
// firstConnect: true,
// connected: false,
// connectCount: 0,
// initedSocketIO: false,
// connectionErrorMsg: `${this.$t("Cannot connect to the socket server.")} ${this.$t("Reconnecting...")}`,
// showReverseProxyGuide: true,
// connecting: false,
// });
// const info = ref({});
// const remember = ref((localStorage.remember !== "0"));
// const loggedIn = ref(false);
// const allowLoginDialog = ref(false);
// const username = ref(null);
// const composeTemplate = ref("");
// }

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
<transition ref="tableContainer" name="slide-fade" appear>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">
{{ $t("home") }}
{{ t("home") }}
</h1>
<div class="row first-row">
@ -12,78 +12,78 @@
<div class="shadow-box big-padding text-center mb-4">
<div class="row">
<div class="col">
<h3>{{ $t("active") }}</h3>
<h3>{{ t("active") }}</h3>
<span class="num active">{{ activeNum }}</span>
</div>
<div class="col">
<h3>{{ $t("exited") }}</h3>
<h3>{{ t("exited") }}</h3>
<span class="num exited">{{ exitedNum }}</span>
</div>
<div class="col">
<h3>{{ $t("inactive") }}</h3>
<h3>{{ t("inactive") }}</h3>
<span class="num inactive">{{ inactiveNum }}</span>
</div>
</div>
</div>
<!-- Docker Run -->
<h2 class="mb-3">{{ $t("Docker Run") }}</h2>
<h2 class="mb-3">{{ t("Docker Run") }}</h2>
<div class="mb-3">
<textarea id="name" v-model="dockerRunCommand" type="text" class="form-control docker-run" required placeholder="docker run ..."></textarea>
</div>
<button class="btn-normal btn mb-4" @click="convertDockerRun">{{ $t("Convert to Compose") }}</button>
<button class="btn-normal btn mb-4" @click="convertDockerRun">{{ t("Convert to Compose") }}</button>
</div>
<!-- Right -->
<div class="col-md-5">
<!-- Agent List -->
<div class="shadow-box big-padding">
<h4 class="mb-3">{{ $tc("dockgeAgent", 2) }} <span class="badge bg-warning" style="font-size: 12px;">beta</span></h4>
<h4 class="mb-3">{{ t("dockgeAgent", 2) }} <span class="badge bg-warning" style="font-size: 12px;">beta</span></h4>
<div v-for="(agent, endpoint) in $root.agentList" :key="endpoint" class="mb-3 agent">
<div v-for="(agent, endpoint) in socket.agentList.value" :key="endpoint" class="mb-3 agent">
<!-- Agent Status -->
<template v-if="$root.agentStatusList[endpoint]">
<span v-if="$root.agentStatusList[endpoint] === 'online'" class="badge bg-primary me-2">{{ $t("agentOnline") }}</span>
<span v-else-if="$root.agentStatusList[endpoint] === 'offline'" class="badge bg-danger me-2">{{ $t("agentOffline") }}</span>
<span v-else class="badge bg-secondary me-2">{{ $t($root.agentStatusList[endpoint]) }}</span>
<template v-if="socket.agentStatusList.value[endpoint]">
<span v-if="socket.agentStatusList.value[endpoint] === 'online'" class="badge bg-primary me-2">{{ t("agentOnline") }}</span>
<span v-else-if="socket.agentStatusList.value[endpoint] === 'offline'" class="badge bg-danger me-2">{{ t("agentOffline") }}</span>
<span v-else class="badge bg-secondary me-2">{{ t(socket.agentStatusList.value[endpoint]) }}</span>
</template>
<!-- Agent Display Name -->
<span v-if="endpoint === ''">{{ $t("currentEndpoint") }}</span>
<span v-if="endpoint === ''">{{ t("currentEndpoint") }}</span>
<a v-else :href="agent.url" target="_blank">{{ endpoint }}</a>
<!-- Remove Button -->
<font-awesome-icon v-if="endpoint !== ''" class="ms-2 remove-agent" icon="trash" @click="showRemoveAgentDialog[agent.url] = !showRemoveAgentDialog[agent.url]" />
<!-- Remoe Agent Dialog -->
<BModal v-model="showRemoveAgentDialog[agent.url]" :okTitle="$t('removeAgent')" okVariant="danger" @ok="removeAgent(agent.url)">
<BModal v-model="showRemoveAgentDialog[agent.url]" :okTitle="t('removeAgent')" okVariant="danger" @ok="removeAgent(agent.url)">
<p>{{ agent.url }}</p>
{{ $t("removeAgentMsg") }}
{{ t("removeAgentMsg") }}
</BModal>
</div>
<button v-if="!showAgentForm" class="btn btn-normal" @click="showAgentForm = !showAgentForm">{{ $t("addAgent") }}</button>
<button v-if="!showAgentForm" class="btn btn-normal" @click="showAgentForm = !showAgentForm">{{ t("addAgent") }}</button>
<!-- Add Agent Form -->
<form v-if="showAgentForm" @submit.prevent="addAgent">
<div class="mb-3">
<label for="url" class="form-label">{{ $t("dockgeURL") }}</label>
<label for="url" class="form-label">{{ t("dockgeURL") }}</label>
<input id="url" v-model="agent.url" type="url" class="form-control" required placeholder="http://">
</div>
<div class="mb-3">
<label for="username" class="form-label">{{ $t("Username") }}</label>
<label for="username" class="form-label">{{ t("Username") }}</label>
<input id="username" v-model="agent.username" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ $t("Password") }}</label>
<label for="password" class="form-label">{{ t("Password") }}</label>
<input id="password" v-model="agent.password" type="password" class="form-control" required autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary" :disabled="connectingAgent">
<template v-if="connectingAgent">{{ $t("connecting") }}</template>
<template v-else>{{ $t("connect") }}</template>
<template v-if="connectingAgent">{{ t("connecting") }}</template>
<template v-else>{{ t("connect") }}</template>
</button>
</form>
</div>
@ -94,199 +94,191 @@
<router-view ref="child" />
</template>
<script>
import { statusNameShort } from "../../../common/util-common";
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from "vue";
import {useSocket} from "../sockets";
import {toastRes} from "../toast";
import {useRouter} from "vue-router";
import {statusNameShort} from "../../../common/util-common";
const { t } = useI18n();
const socket = useSocket();
const router = useRouter();
export default {
components: {
withDefaults(defineProps<{
type: number
}>(), {
type: 0
});
},
props: {
calculatedHeight: {
type: Number,
default: 0
}
},
data() {
return {
page: 1,
perPage: 25,
initialPerPage: 25,
paginationConfig: {
hideCount: true,
chunksNavigation: "scroll",
},
importantHeartBeatListLength: 0,
displayedRecords: [],
dockerRunCommand: "",
showAgentForm: false,
showRemoveAgentDialog: {},
connectingAgent: false,
agent: {
const tableContainer = ref();
const page = ref<number>(1);
const perPage = ref<number>(25);
const initialPerPage = ref<number>(25);
const paginationConfig = ref({
hideCount: true,
chunksNavigation: "scroll",
});
const importantHeartBeatListLength = ref(0);
const displayedRecords = ref([]);
const dockerRunCommand = ref("");
const showAgentForm = ref(false);
const showRemoveAgentDialog = ref({});
const connectingAgent = ref(false);
const agent = ref({
url: "http://",
username: "",
password: "",
});
const activeNum = computed(() => {
return getStatusNum("active");
});
const inactiveNum = computed(() => {
return getStatusNum("inactive");
});
const exitedNum = computed(() => {
return getStatusNum("exited");
});
watch(perPage, () => {
nextTick(() => {
getImportantHeartbeatListPaged();
});
});
watch(page, () => {
getImportantHeartbeatListPaged();
});
onMounted(() => {
initialPerPage.value = perPage.value;
window.addEventListener("resize", updatePerPage);
updatePerPage();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updatePerPage);
});
const addAgent = () => {
connectingAgent.value = true;
socket.getSocket().emit("addAgent", agent.value, (res) => {
toastRes(res);
if (res.ok) {
showAgentForm.value = false;
agent.value = {
url: "http://",
username: "",
password: "",
}
};
},
};
}
computed: {
activeNum() {
return this.getStatusNum("active");
},
inactiveNum() {
return this.getStatusNum("inactive");
},
exitedNum() {
return this.getStatusNum("exited");
},
},
connectingAgent.value = false;
});
};
watch: {
perPage() {
this.$nextTick(() => {
this.getImportantHeartbeatListPaged();
});
},
const removeAgent = (url: string) => {
socket.getSocket().emit("removeAgent", url, (res) => {
if (res.ok) {
toastRes(res);
page() {
this.getImportantHeartbeatListPaged();
},
},
let urlObj = new URL(url);
let endpoint = urlObj.host;
mounted() {
this.initialPerPage = this.perPage;
// Remove the stack list and status list of the removed agent
delete socket.allAgentStackList.value[endpoint];
}
});
};
window.addEventListener("resize", this.updatePerPage);
this.updatePerPage();
},
const getStatusNum = (statusName: string) => {
let num = 0;
beforeUnmount() {
window.removeEventListener("resize", this.updatePerPage);
},
for (let stackName in socket.completeStackList.value) {
const stack = socket.completeStackList.value[stackName];
if (statusNameShort(stack.status) === statusName) {
num += 1;
}
}
return num;
};
methods: {
const convertDockerRun = () => {
if (dockerRunCommand.value.trim() === "docker run") {
throw new Error("Please enter a docker run command");
}
addAgent() {
this.connectingAgent = true;
this.$root.getSocket().emit("addAgent", this.agent, (res) => {
this.$root.toastRes(res);
// composerize is working in dev, but after "vite build", it is not working
// So pass to backend to do the conversion
socket.getSocket().emit("composerize", dockerRunCommand.value, (res) => {
if (res.ok) {
socket.composeTemplate = res.composeTemplate;
router.push("/compose");
} else {
toastRes(res);
}
});
};
if (res.ok) {
this.showAgentForm = false;
this.agent = {
url: "http://",
username: "",
password: "",
};
}
/**
* Updates the displayed records when a new important heartbeat arrives.
* @param {object} heartbeat - The heartbeat object received.
* @returns {void}
*/
const onNewImportantHeartbeat = (heartbeat: object): void => {
if (page.value === 1) {
displayedRecords.value.unshift(heartbeat);
if (displayedRecords.value.length > perPage.value) {
displayedRecords.value.pop();
}
importantHeartBeatListLength.value += 1;
}
};
this.connectingAgent = false;
});
},
/**
* Retrieves the length of the important heartbeat list for all monitors.
* @returns {void}
*/
const getImportantHeartbeatListLength = (): void => {
socket.getSocket().emit("monitorImportantHeartbeatListCount", null, (res) => {
if (res.ok) {
importantHeartBeatListLength.value = res.count;
getImportantHeartbeatListPaged();
}
});
};
removeAgent(url) {
this.$root.getSocket().emit("removeAgent", url, (res) => {
if (res.ok) {
this.$root.toastRes(res);
/**
* Retrieves the important heartbeat list for the current page.
* @returns {void}
*/
const getImportantHeartbeatListPaged = (): void => {
const offset = (page.value - 1) * perPage.value;
socket.getSocket().emit("monitorImportantHeartbeatListPaged", null, offset, perPage.value, (res) => {
if (res.ok) {
displayedRecords.value = res.data;
}
});
};
let urlObj = new URL(url);
let endpoint = urlObj.host;
/**
* Updates the number of items shown per page based on the available height.
* @returns {void}
*/
const updatePerPage = (): void => {
const tableContainerHeight = tableContainer.value?.offsetHeight;
const availableHeight = window.innerHeight - tableContainerHeight;
const additionalPerPage = Math.floor(availableHeight / 58);
// Remove the stack list and status list of the removed agent
delete this.$root.allAgentStackList[endpoint];
}
});
},
getStatusNum(statusName) {
let num = 0;
for (let stackName in this.$root.completeStackList) {
const stack = this.$root.completeStackList[stackName];
if (statusNameShort(stack.status) === statusName) {
num += 1;
}
}
return num;
},
convertDockerRun() {
if (this.dockerRunCommand.trim() === "docker run") {
throw new Error("Please enter a docker run command");
}
// composerize is working in dev, but after "vite build", it is not working
// So pass to backend to do the conversion
this.$root.getSocket().emit("composerize", this.dockerRunCommand, (res) => {
if (res.ok) {
this.$root.composeTemplate = res.composeTemplate;
this.$router.push("/compose");
} else {
this.$root.toastRes(res);
}
});
},
/**
* Updates the displayed records when a new important heartbeat arrives.
* @param {object} heartbeat - The heartbeat object received.
* @returns {void}
*/
onNewImportantHeartbeat(heartbeat) {
if (this.page === 1) {
this.displayedRecords.unshift(heartbeat);
if (this.displayedRecords.length > this.perPage) {
this.displayedRecords.pop();
}
this.importantHeartBeatListLength += 1;
}
},
/**
* Retrieves the length of the important heartbeat list for all monitors.
* @returns {void}
*/
getImportantHeartbeatListLength() {
this.$root.getSocket().emit("monitorImportantHeartbeatListCount", null, (res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
});
},
/**
* Retrieves the important heartbeat list for the current page.
* @returns {void}
*/
getImportantHeartbeatListPaged() {
const offset = (this.page - 1) * this.perPage;
this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", null, offset, this.perPage, (res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
});
},
/**
* Updates the number of items shown per page based on the available height.
* @returns {void}
*/
updatePerPage() {
const tableContainer = this.$refs.tableContainer;
const tableContainerHeight = tableContainer.offsetHeight;
const availableHeight = window.innerHeight - tableContainerHeight;
const additionalPerPage = Math.floor(availableHeight / 58);
if (additionalPerPage > 0) {
this.perPage = Math.max(this.initialPerPage, this.perPage + additionalPerPage);
} else {
this.perPage = this.initialPerPage;
}
},
},
if (additionalPerPage > 0) {
perPage.value = Math.max(initialPerPage.value, perPage.value + additionalPerPage);
} else {
perPage.value = initialPerPage.value;
}
};
</script>

View File

@ -1,121 +1,128 @@
<script lang="ts">
import { defineComponent } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { log } from "../../../backend/log";
<script setup lang="ts">
import {useRoute} from "vue-router";
let lastSelectTime = 0;
export default defineComponent({
name: "Files",
components: { FontAwesomeIcon },
data() {
return {
files: [],
selected: [],
currentPath: "/",
newFileData: {
isFolder: false,
fileName: "",
},
stack: {}
};
},
computed: {
stackName() {
return this.$route.params.stackName;
},
import { ref, computed, onMounted } from "vue";
import {useSocket} from "../sockets";
import {toastError, toastRes, toastSuccess} from "../toast";
import FileEditor from "../components/FileEditor.vue";
import Confirm from "../components/Confirm.vue";
endpoint() {
return this.stack.endpoint || this.$route.params.endpoint || "";
},
// Use
const route = useRoute();
const socket = useSocket();
newFileLabel() {
return this.newFileData.isFolder ? {
title: "newFolder",
placeholder: "folderName",
} : {
title: "newFile",
placeholder: "fileName",
};
},
},
mounted() {
this.loadDir("/");
},
methods: {
select(ev, file) {
const isMac = window.navigator.userAgent.toLowerCase().includes("mac");
if (isMac ? ev.metaKey : ev.ctrlKey) {
//
if (this.selected.includes(file.name)) {
this.selected = this.selected.filter((f) => f !== file.name);
} else {
this.selected.push(file.name);
}
} else {
//
if (this.selected.length === 1 && file.name === this.selected[0] && Date.now() - lastSelectTime < 1000) {
console.log("Double Click");
this.open(file);
}
this.selected = [ file.name ];
lastSelectTime = Date.now();
}
},
// Refs
const fileEditor = ref<FileEditor>();
const createFileModal = ref<Confirm>();
open(file) {
console.log(file);
if (file.folder) {
// Open Folder
this.loadDir(file.path);
} else {
// Open File
this.$refs.fileEditor.open(file);
}
},
// Reactive variables
const files = ref([]);
const selected = ref<string[]>([]);
const currentPath = ref<string>("/");
const newFileData = ref<{ isFolder: boolean; fileName: string }>({
isFolder: false,
fileName: "",
});
const stack = ref<Record<string, object>>({});
loadDir(dir: string) {
this.$root.emitAgent(this.endpoint, "listDir", this.stackName, dir, (res) => {
console.log(res.files);
this.currentPath = dir;
this.files = res.files;
});
},
// Computed properties
const stackName = computed(() => {
return route.params.stackName;
});
openNewFileModel(isFolder: boolean = false) {
this.newFileData.isFolder = isFolder;
this.newFileData.fileName = "";
this.newFileData.fileName = "";
this.$refs.createFileModal.show();
},
const endpoint = computed(() => {
return stack.value.endpoint || route.params.endpoint || "";
});
newFile() {
// Check file
const filter = this.files.filter(f => f.name === this.newFileData.fileName);
if (filter.length > 0) {
this.$root.toastError("File Exist");
return;
}
this.$root.emitAgent(this.endpoint, "createFile", this.stackName, this.currentPath, this.newFileData.isFolder
, this.newFileData.fileName, (res) => {
if (res.ok) {
this.$root.toastSuccess("Created");
this.loadDir(this.currentPath);
}
});
},
loadStack() {
this.processing = true;
this.$root.emitAgent(this.endpoint, "getStack", this.stack.name, (res) => {
if (res.ok) {
this.stack = res.stack;
} else {
this.$root.toastRes(res);
}
})
const newFileLabel = computed(() => {
return newFileData.value.isFolder ? {
title: "newFolder",
placeholder: "folderName",
} : {
title: "newFile",
placeholder: "fileName",
};
});
// Methods
const select = (ev: MouseEvent, file) => {
const isMac = window.navigator.userAgent.toLowerCase().includes("mac");
if (isMac ? ev.metaKey : ev.ctrlKey) {
// Multiple selection
if (selected.value.includes(file.name)) {
selected.value = selected.value.filter((f) => f !== file.name);
} else {
selected.value.push(file.name);
}
},
} else {
// Single selection
if (selected.value.length === 1 && file.name === selected.value[0] && Date.now() - lastSelectTime < 1000) {
console.log("Double Click");
open(file);
}
selected.value = [file.name];
lastSelectTime = Date.now();
}
};
const open = (file) => {
console.log(file);
if (file.folder) {
// Open Folder
loadDir(file.path);
} else {
// Open File
fileEditor.value?.open(file);
}
};
const loadDir = (dir: string) => {
socket.emitAgent(endpoint.value, "listDir", stackName.value, dir, (res: { files: any[] }) => {
console.log(res.files);
currentPath.value = dir;
files.value = res.files;
});
};
const openNewFileModel = (isFolder = false) => {
newFileData.value.isFolder = isFolder;
newFileData.value.fileName = "";
newFileData.value.fileName = "";
createFileModal.value?.show();
};
const newFile = () => {
// Check file existence
const filter = files.value.filter(f => f.name === newFileData.value.fileName);
if (filter.length > 0) {
toastError("File Exist");
return;
}
socket.emitAgent(endpoint.value, "createFile", stackName.value, currentPath.value, newFileData.value.isFolder
, newFileData.value.fileName, (res: { ok: boolean }) => {
if (res.ok) {
toastSuccess("Created");
loadDir(currentPath.value);
}
});
};
const loadStack = () => {
processing.value = true;
socket.emitAgent(endpoint.value, "getStack", stack.value.name, (res: { ok: boolean; stack: Record<string, any> }) => {
if (res.ok) {
stack.value = res.stack;
} else {
toastRes(res);
}
});
};
// Lifecycle hook
onMounted(() => {
loadDir("/");
});
</script>

View File

@ -1,4 +1,4 @@
import {createRouter, createWebHistory, RouteRecordRaw} from "vue-router";
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Layout from "./layouts/Layout.vue";
import Setup from "./pages/Setup.vue";

417
frontend/src/sockets.ts Normal file
View File

@ -0,0 +1,417 @@
import { App, computed, ComputedRef, getCurrentInstance, inject, Ref, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { io } from "socket.io-client";
import { AgentSocket } from "../../common/agent-socket";
import jwtDecode from "jwt-decode";
import { Terminal } from "@xterm/xterm";
import { useRouter } from "vue-router";
import { toastError, toastRes } from "./toast";
import { i18n } from "./i18n";
let socket : Socket;
let terminalMap : Map<string, Terminal> = new Map();
export const SocketPlugin = {
install(app: App) {
app.provide("socket", Socket());
}
};
export const Socket = () => {
const { t } = i18n.global;
const socketIO = ref({
token: null,
firstConnect: true,
connected: false,
connectCount: 0,
initedSocketIO: false,
connectionErrorMsg: `${t("Cannot connect to the socket server.")} ${t("Reconnecting...")}`,
showReverseProxyGuide: true,
connecting: false,
});
const info = ref({});
const remember = ref(localStorage.remember !== "0");
const loggedIn = ref(false);
const allowLoginDialog = ref(false);
const username = ref(null);
const composeTemplate = ref("");
const stackList = ref<Record<string, object>>({});
// All stack list from all agents
const allAgentStackList = ref<Record<string, object>>({});
// online / offline / connecting
const agentStatusList = ref<Record<string, string>>({});
// Agent List
const agentList = ref<Record<string, object>>({});
/** COMPUTE **/
const agentCount = computed(() => {
return Object.keys(agentList.value).length;
});
const completeStackList = computed(() => {
let list: Record<string, object> = {};
for (let stackName in stackList.value) {
list[stackName + "_"] = stackList.value[stackName];
}
for (let endpoint in allAgentStackList.value) {
let instance = allAgentStackList.value[endpoint];
for (let stackName in instance.stackList) {
list[stackName + "_" + endpoint] = instance.stackList[stackName];
}
}
return list;
});
const usernameFirstChar = computed(() => {
if (typeof username.value == "string" && username.value.length >= 1) {
return username.value.charAt(0).toUpperCase();
} else {
return "🐬";
}
});
/**
* Frontend Version
* It should be compiled to a static value while building the frontend.
* Please see ./frontend/vite.config.ts, it is defined via vite.js
* @returns {string}
*/
const frontendVersion = computed(() => {
// eslint-disable-next-line no-undef
return FRONTEND_VERSION;
});
/**
* Are both frontend and backend in the same version?
* @returns {boolean}
*/
const isFrontendBackendVersionMatched = computed(() => {
if (!info.value.version) {
return true;
}
return info.value.version === frontendVersion.value;
});
// WATCH
watch(() => socketIO.value.connected, () => {
if (socketIO.value.connected) {
agentStatusList.value[""] = "online";
} else {
agentStatusList.value[""] = "offline";
}
});
watch(remember, () => {
localStorage.remember = (remember.value) ? "1" : "0";
});
watch(() => info.value.version, (to, from) => {
if (from && from !== to) {
window.location.reload();
}
});
const endpointDisplayFunction = (endpoint : string) => {
if (endpoint) {
return endpoint;
} else {
return t("currentEndpoint");
}
};
/**
* Initialize connection to socket server
* @param bypass Should the check for if we
* are on a status page be bypassed?
*/
const initSocketIO = (bypass = false) => {
// No need to re-init
if (socketIO.value.initedSocketIO) {
return;
}
socketIO.value.initedSocketIO = true;
let url : string;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
url = location.protocol + "//" + location.hostname + ":5001";
} else {
url = location.protocol + "//" + location.host;
}
let connectingMsgTimeout = setTimeout(() => {
socketIO.value.connecting = true;
}, 1500);
socket = io(url);
// Handling events from agents
let agentSocket = new AgentSocket();
socket.on("agent", (eventName : unknown, ...args : unknown[]) => {
agentSocket.call(eventName, ...args);
});
socket.on("connect", () => {
console.log("Connected to the socket server");
clearTimeout(connectingMsgTimeout);
socketIO.value.connecting = false;
socketIO.value.connectCount++;
socketIO.value.connected = true;
socketIO.value.showReverseProxyGuide = false;
const token = storage().token;
if (token) {
if (token !== "autoLogin") {
console.log("Logging in by token");
loginByToken(token);
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! loggedIn.value) {
allowLoginDialog.value = true;
storage().removeItem("token");
}
}, 5000);
}
} else {
allowLoginDialog.value = true;
}
socketIO.value.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect");
socketIO.value.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
socketIO.value.connected = false;
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
socketIO.value.connectionErrorMsg = `${t("Cannot connect to the socket server.")} [${err}] ${t("reconnecting...")}`;
socketIO.value.showReverseProxyGuide = true;
socketIO.value.connected = false;
socketIO.value.firstConnect = false;
socketIO.value.connecting = false;
});
// Custom Events
socket.on("info", (i) => {
info.value = i;
});
socket.on("autoLogin", () => {
loggedIn.value = true;
storage().token = "autoLogin";
socketIO.value.token = "autoLogin";
allowLoginDialog.value = false;
afterLogin();
});
socket.on("setup", () => {
console.log("setup");
useRouter().push("/setup");
});
agentSocket.on("terminalWrite", (terminalName, data) => {
const terminal = terminalMap.get(terminalName);
if (!terminal) {
//console.error("Terminal not found: " + terminalName);
return;
}
terminal.write(data);
});
agentSocket.on("stackList", (res) => {
if (res.ok) {
if (!res.endpoint) {
stackList.value = res.stackList;
} else {
if (!allAgentStackList.value[res.endpoint]) {
allAgentStackList.value[res.endpoint] = {
stackList: {},
};
}
allAgentStackList.value[res.endpoint].stackList = res.stackList;
}
}
});
socket.on("stackStatusList", (res) => {
if (res.ok) {
for (let stackName in res.stackStatusList) {
const stackObj = stackList.value[stackName];
if (stackObj) {
stackObj.status = res.stackStatusList[stackName];
}
}
}
});
socket.on("agentStatus", (res) => {
agentStatusList.value[res.endpoint] = res.status;
if (res.msg) {
toastError(res.msg);
}
});
socket.on("agentList", (res) => {
console.log(res);
if (res.ok) {
agentList.value = res.agentList;
}
});
socket.on("refresh", () => {
location.reload();
});
};
/**
* The storage currently in use
* @returns Current storage
*/
const storage = () : Storage => {
return (remember.value) ? localStorage : sessionStorage;
};
const getSocket = () : Socket => {
return socket;
};
const emitAgent = (endpoint : string | undefined, eventName : string, ...args : unknown[]) => {
if (!endpoint) {
return;
}
getSocket().emit("agent", endpoint, eventName, ...args);
};
/**
* Get payload of JWT cookie
* @returns {(object | undefined)} JWT payload
*/
const getJWTPayload = () => {
const jwtToken = storage().token;
if (jwtToken && jwtToken !== "autoLogin") {
return jwtDecode(jwtToken);
}
return undefined;
};
/**
* Send request to log user in
* @param {string} username Username to log in with
* @param {string} password Password to log in with
* @param {string} token User token
* @param {loginCB} callback Callback to call with result
* @returns {void}
*/
const login = (username : string, password : string, token : string, callback) => {
getSocket().emit("login", {
username,
password,
token,
}, (res) => {
if (res.tokenRequired) {
callback(res);
}
if (res.ok) {
storage().token = res.token;
socketIO.value.token = res.token;
loggedIn.value = true;
username = getJWTPayload()?.username;
afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}
callback(res);
});
};
/**
* Log in using a token
* @param {string} token Token to log in with
* @returns {void}
*/
const loginByToken = (token : string) => {
socket.emit("loginByToken", token, (res) => {
allowLoginDialog.value = true;
if (!res.ok) {
logout();
} else {
loggedIn.value = true;
username.value = getJWTPayload()?.username;
afterLogin();
}
});
};
/**
* Log out of the web application
*/
const logout = () => {
socket.emit("logout", () => { });
storage().removeItem("token");
socketIO.value.token = null;
loggedIn.value = false;
username.value = null;
clearData();
};
const clearData = () => {};
const afterLogin = () => {};
const bindTerminal = (endpoint : string, terminalName : string, terminal : Terminal) => {
// Load terminal, get terminal screen
emitAgent(endpoint, "terminalJoin", terminalName, (res) => {
if (res.ok) {
terminal.write(res.buffer);
terminalMap.set(terminalName, terminal);
} else {
toastRes(res);
}
});
};
const unbindTerminal = (terminalName : string) => {
terminalMap.delete(terminalName);
};
initSocketIO();
return {
socketIO,
loggedIn,
username,
composeTemplate,
allowLoginDialog,
usernameFirstChar,
completeStackList,
stackList,
agentCount,
allAgentStackList,
agentStatusList,
agentList,
info,
endpointDisplayFunction,
getSocket,
logout,
emitAgent
};
};
export const useSocket = (): ReturnType<typeof Socket> => inject("socket") as ReturnType<typeof Socket>;

69
frontend/src/theme.ts Normal file
View File

@ -0,0 +1,69 @@
import {computed, inject, onMounted, ref, watch} from "vue";
import { useRoute } from "vue-router";
export declare type SystemTheme = "dark" | "light" | "auto"
export const THEME_INJECT_KEY = "theme";
export const theme = () => {
const system = ref<SystemTheme>((window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light");
const userTheme = ref<SystemTheme>(localStorage.theme);
const statusPageTheme = ref<SystemTheme>("light");
const forceStatusPageTheme = ref<boolean>(false);
const path = ref<string>("");
const theme = computed(() => {
if (userTheme.value === "auto") {
return system.value;
}
return userTheme.value;
});
const isDark = computed(() => {
return theme.value === "dark";
});
const updateThemeColorMeta = () => {
if (theme.value === "dark") {
document.querySelector("#theme-color")?.setAttribute("content", "#161B22");
} else {
document.querySelector("#theme-color")?.setAttribute("content", "#5cdd8b");
}
};
watch(() => useRoute()?.fullPath, (routePath) => {
path.value = routePath;
});
watch(userTheme, (val) => {
localStorage.theme = val;
});
watch(theme, (to, from) => {
document.body.classList.remove(from);
document.body.classList.add(theme.value);
updateThemeColorMeta();
});
onMounted(() => {
if (! userTheme.value) {
userTheme.value = "dark";
}
document.body.classList.add(theme.value);
updateThemeColorMeta();
});
return {
system,
userTheme,
statusPageTheme,
forceStatusPageTheme,
path,
theme,
isDark,
updateThemeColorMeta
};
};
export const useTheme = (): ReturnType<typeof theme> => inject(THEME_INJECT_KEY) as ReturnType<typeof theme>;

38
frontend/src/toast.ts Normal file
View File

@ -0,0 +1,38 @@
import {useToast} from "vue-toastification";
import {i18n} from "./i18n";
const toast = useToast();
const {t} = i18n.global;
export const toastRes = (res: unknown) => {
if (typeof res !== "object") {
return;
}
let msg = res.msg;
if (res.msgi18n) {
if (msg != null && typeof msg === "object") {
msg = t(msg.key, msg.values);
} else {
msg = t(msg);
}
}
if (res.ok) {
toast.success(msg);
} else {
toast.error(msg);
}
};
export const toastSuccess = (msg: string) => {
toast.success(t(msg));
};
/**
* Show an error toast
* @param {string} msg Message to show
* @returns {void}
*/
export const toastError = (msg: string) => {
toast.error(t(msg));
};

View File

@ -1,13 +1,16 @@
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"module": "esnext",
"target": "esnext",
"strict": true,
"moduleResolution": "bundler",
"skipLibCheck": true
"moduleResolution": "node",
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": [
"backend/**/*",
"common/**/*"
"common/**/*",
"frontend/**/*"
]
}