mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-12-18 21:38:57 +08:00
feat: Add support for offline version. (#10395)
This commit is contained in:
parent
725d03b41e
commit
4e8c5a21f9
23 changed files with 54 additions and 13 deletions
|
|
@ -14,6 +14,7 @@ type Base struct {
|
||||||
Mode string `mapstructure:"mode"` // xpack [ Enable / Disable ]
|
Mode string `mapstructure:"mode"` // xpack [ Enable / Disable ]
|
||||||
IsDemo bool `mapstructure:"is_demo"`
|
IsDemo bool `mapstructure:"is_demo"`
|
||||||
InstallDir string `mapstructure:"install_dir"`
|
InstallDir string `mapstructure:"install_dir"`
|
||||||
|
IsOffLine bool `mapstructure:"is_offline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteURL struct {
|
type RemoteURL struct {
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ func (b *BaseApi) GetLoginSetting(c *gin.Context) {
|
||||||
res := &dto.LoginSetting{
|
res := &dto.LoginSetting{
|
||||||
IsDemo: global.CONF.Base.IsDemo,
|
IsDemo: global.CONF.Base.IsDemo,
|
||||||
IsIntl: global.CONF.Base.IsIntl,
|
IsIntl: global.CONF.Base.IsIntl,
|
||||||
|
IsOffLine: global.CONF.Base.IsOffLine,
|
||||||
Language: settingInfo.Language,
|
Language: settingInfo.Language,
|
||||||
MenuTabs: settingInfo.MenuTabs,
|
MenuTabs: settingInfo.MenuTabs,
|
||||||
PanelName: settingInfo.PanelName,
|
PanelName: settingInfo.PanelName,
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ type AppstoreConfig struct {
|
||||||
type LoginSetting struct {
|
type LoginSetting struct {
|
||||||
IsDemo bool `json:"isDemo"`
|
IsDemo bool `json:"isDemo"`
|
||||||
IsIntl bool `json:"isIntl"`
|
IsIntl bool `json:"isIntl"`
|
||||||
|
IsOffLine bool `json:"isOffLine"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
MenuTabs string `json:"menuTabs"`
|
MenuTabs string `json:"menuTabs"`
|
||||||
PanelName string `json:"panelName"`
|
PanelName string `json:"panelName"`
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ func NewIUpgradeService() IUpgradeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UpgradeService) SearchUpgrade() (*dto.UpgradeInfo, error) {
|
func (u *UpgradeService) SearchUpgrade() (*dto.UpgradeInfo, error) {
|
||||||
|
if global.CONF.Base.IsOffLine {
|
||||||
|
return &dto.UpgradeInfo{}, nil
|
||||||
|
}
|
||||||
var upgrade dto.UpgradeInfo
|
var upgrade dto.UpgradeInfo
|
||||||
currentVersion, err := settingRepo.Get(repo.WithByKey("SystemVersion"))
|
currentVersion, err := settingRepo.Get(repo.WithByKey("SystemVersion"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type Base struct {
|
||||||
Language string `mapstructure:"language"`
|
Language string `mapstructure:"language"`
|
||||||
IsDemo bool `mapstructure:"is_demo"`
|
IsDemo bool `mapstructure:"is_demo"`
|
||||||
IsIntl bool `mapstructure:"is_intl"`
|
IsIntl bool `mapstructure:"is_intl"`
|
||||||
|
IsOffLine bool `mapstructure:"is_offline"`
|
||||||
Version string `mapstructure:"version"`
|
Version string `mapstructure:"version"`
|
||||||
InstallDir string `mapstructure:"install_dir"`
|
InstallDir string `mapstructure:"install_dir"`
|
||||||
ChangeUserInfo string `mapstructure:"change_user_info"`
|
ChangeUserInfo string `mapstructure:"change_user_info"`
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ func Init() {
|
||||||
global.CONF.Base.InstallDir = baseDir
|
global.CONF.Base.InstallDir = baseDir
|
||||||
global.CONF.Base.IsDemo = v.GetBool("base.is_demo")
|
global.CONF.Base.IsDemo = v.GetBool("base.is_demo")
|
||||||
global.CONF.Base.IsIntl = v.GetBool("base.is_intl")
|
global.CONF.Base.IsIntl = v.GetBool("base.is_intl")
|
||||||
|
global.CONF.Base.IsOffLine = v.GetBool("base.is_offline")
|
||||||
global.CONF.Base.Version = version
|
global.CONF.Base.Version = version
|
||||||
global.CONF.Base.Username = username
|
global.CONF.Base.Username = username
|
||||||
global.CONF.Base.Password = password
|
global.CONF.Base.Password = password
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,15 @@
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-wrap items-center">
|
<div class="flex flex-wrap items-center">
|
||||||
<el-link underline="never" type="primary" @click="toLxware">
|
<el-link underline="never" type="primary" @click="toLxware">
|
||||||
{{ $t(!isMasterPro ? 'license.community' : 'license.pro') }}
|
<span v-if="isMasterPro">
|
||||||
|
{{ $t('license.pro') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="isOffLine">
|
||||||
|
{{ $t('license.offLine') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t('license.community') }}
|
||||||
|
</span>
|
||||||
</el-link>
|
</el-link>
|
||||||
<el-link
|
<el-link
|
||||||
underline="never"
|
underline="never"
|
||||||
|
|
@ -56,7 +64,7 @@ import { GlobalStore } from '@/store';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
const globalStore = GlobalStore();
|
const globalStore = GlobalStore();
|
||||||
const { docsUrl } = storeToRefs(globalStore);
|
const { docsUrl, isOffLine } = storeToRefs(globalStore);
|
||||||
const upgradeRef = ref();
|
const upgradeRef = ref();
|
||||||
const releasesRef = ref();
|
const releasesRef = ref();
|
||||||
const isMasterPro = computed(() => {
|
const isMasterPro = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -1982,6 +1982,7 @@ const message = {
|
||||||
backupRecoverMessage: 'Please enter the compression or decompression password (leave blank to not set)',
|
backupRecoverMessage: 'Please enter the compression or decompression password (leave blank to not set)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: 'Offline',
|
||||||
community: 'OSS',
|
community: 'OSS',
|
||||||
oss: 'Open Source Software',
|
oss: 'Open Source Software',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|
|
||||||
|
|
@ -1899,6 +1899,7 @@ const message = {
|
||||||
backupRecoverMessage: '圧縮または減圧パスワードを入力してください(設定しないように空白のままにしてください)',
|
backupRecoverMessage: '圧縮または減圧パスワードを入力してください(設定しないように空白のままにしてください)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: 'オフライン版',
|
||||||
community: '無料',
|
community: '無料',
|
||||||
oss: '無料',
|
oss: '無料',
|
||||||
pro: '専門',
|
pro: '専門',
|
||||||
|
|
|
||||||
|
|
@ -1870,6 +1870,7 @@ const message = {
|
||||||
backupRecoverMessage: '압축 또는 압축 해제 비밀번호를 입력하세요 (설정하지 않으려면 비워 두세요)',
|
backupRecoverMessage: '압축 또는 압축 해제 비밀번호를 입력하세요 (설정하지 않으려면 비워 두세요)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: '오프라인 버전',
|
||||||
community: 'OSS',
|
community: 'OSS',
|
||||||
oss: '오픈 소스 소프트웨어',
|
oss: '오픈 소스 소프트웨어',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|
|
||||||
|
|
@ -1958,6 +1958,7 @@ const message = {
|
||||||
'Sila masukkan kata laluan mampatan atau nyahmampatan (biarkan kosong jika tidak menetapkan)',
|
'Sila masukkan kata laluan mampatan atau nyahmampatan (biarkan kosong jika tidak menetapkan)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: 'Versi Luar Talian',
|
||||||
community: 'OSS',
|
community: 'OSS',
|
||||||
oss: 'Perisian Sumber Terbuka',
|
oss: 'Perisian Sumber Terbuka',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|
|
||||||
|
|
@ -1946,6 +1946,7 @@ const message = {
|
||||||
'Por favor, insira a senha de compressão ou descompressão (deixe em branco para não definir)',
|
'Por favor, insira a senha de compressão ou descompressão (deixe em branco para não definir)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: 'Versão Offline',
|
||||||
community: 'Gratuito',
|
community: 'Gratuito',
|
||||||
oss: 'Open Source Software',
|
oss: 'Open Source Software',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|
|
||||||
|
|
@ -1945,6 +1945,7 @@ const message = {
|
||||||
'Пожалуйста, введите пароль для сжатия или распаковки (оставьте пустым, чтобы не устанавливать)',
|
'Пожалуйста, введите пароль для сжатия или распаковки (оставьте пустым, чтобы не устанавливать)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: 'Офлайн версия',
|
||||||
community: 'OSS',
|
community: 'OSS',
|
||||||
oss: 'Open Source Software',
|
oss: 'Open Source Software',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|
|
||||||
|
|
@ -1999,6 +1999,7 @@ const message = {
|
||||||
backupRecoverMessage: 'Lütfen sıkıştırma veya sıkıştırma açma parolasını girin (ayarlamamak için boş bırakın)',
|
backupRecoverMessage: 'Lütfen sıkıştırma veya sıkıştırma açma parolasını girin (ayarlamamak için boş bırakın)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: 'Çevrimdışı Sürüm',
|
||||||
community: 'OSS',
|
community: 'OSS',
|
||||||
oss: 'Açık Kaynak Yazılım',
|
oss: 'Açık Kaynak Yazılım',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|
|
||||||
|
|
@ -1855,6 +1855,7 @@ const message = {
|
||||||
backupRecoverMessage: '請輸入壓縮或解壓縮密碼(留空則不設定)',
|
backupRecoverMessage: '請輸入壓縮或解壓縮密碼(留空則不設定)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: '離線版',
|
||||||
community: '社區版',
|
community: '社區版',
|
||||||
oss: '社區版',
|
oss: '社區版',
|
||||||
pro: '專業版:',
|
pro: '專業版:',
|
||||||
|
|
|
||||||
|
|
@ -1848,6 +1848,7 @@ const message = {
|
||||||
backupRecoverMessage: '请输入压缩或解压缩密码(留空则不设置)',
|
backupRecoverMessage: '请输入压缩或解压缩密码(留空则不设置)',
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
|
offLine: '离线版',
|
||||||
community: '社区版',
|
community: '社区版',
|
||||||
oss: '社区版',
|
oss: '社区版',
|
||||||
pro: '专业版',
|
pro: '专业版',
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface GlobalState {
|
||||||
isIntl: boolean;
|
isIntl: boolean;
|
||||||
productProExpires: number;
|
productProExpires: number;
|
||||||
isMasterProductPro: boolean;
|
isMasterProductPro: boolean;
|
||||||
|
isOffLine: boolean;
|
||||||
|
|
||||||
currentNode: string;
|
currentNode: string;
|
||||||
currentNodeAddr: string;
|
currentNodeAddr: string;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const GlobalStore = defineStore({
|
||||||
isIntl: false,
|
isIntl: false,
|
||||||
productProExpires: 0,
|
productProExpires: 0,
|
||||||
isMasterProductPro: false,
|
isMasterProductPro: false,
|
||||||
|
isOffLine: false,
|
||||||
|
|
||||||
currentNode: 'local',
|
currentNode: 'local',
|
||||||
currentNodeAddr: '',
|
currentNodeAddr: '',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template #leftToolBar>
|
<template #leftToolBar>
|
||||||
<el-button @click="sync" type="primary" plain :disabled="syncing">
|
<el-button @click="sync" type="primary" plain :disabled="syncing">
|
||||||
<span>{{ syncCustomAppstore ? $t('app.syncCustomApp') : $t('app.syncAppList') }}</span>
|
<span>{{ syncCustomAppstore || isOffLine ? $t('app.syncCustomApp') : $t('app.syncAppList') }}</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="syncLocal" type="primary" plain :disabled="syncing" class="ml-2">
|
<el-button @click="syncLocal" type="primary" plain :disabled="syncing" class="ml-2">
|
||||||
{{ $t('app.syncLocalApp') }}
|
{{ $t('app.syncLocalApp') }}
|
||||||
|
|
@ -74,11 +74,9 @@ import { searchApp, syncApp, syncCutomAppStore, syncLocalApp, getCurrentNodeCust
|
||||||
import Install from '../detail/install/index.vue';
|
import Install from '../detail/install/index.vue';
|
||||||
import router from '@/routers';
|
import router from '@/routers';
|
||||||
import { MsgSuccess } from '@/utils/message';
|
import { MsgSuccess } from '@/utils/message';
|
||||||
import { GlobalStore } from '@/store';
|
|
||||||
import { newUUID } from '@/utils/util';
|
import { newUUID } from '@/utils/util';
|
||||||
import Detail from '../detail/index.vue';
|
import Detail from '../detail/index.vue';
|
||||||
import TaskLog from '@/components/log/task/index.vue';
|
import TaskLog from '@/components/log/task/index.vue';
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import bus from '@/global/bus';
|
import bus from '@/global/bus';
|
||||||
import Tags from '@/views/app-store/components/tag.vue';
|
import Tags from '@/views/app-store/components/tag.vue';
|
||||||
import DockerStatus from '@/views/container/docker-status/index.vue';
|
import DockerStatus from '@/views/container/docker-status/index.vue';
|
||||||
|
|
@ -86,9 +84,8 @@ import NoApp from '@/views/app-store/apps/no-app/index.vue';
|
||||||
import AppCard from '@/views/app-store/apps/app/index.vue';
|
import AppCard from '@/views/app-store/apps/app/index.vue';
|
||||||
import MainDiv from '@/components/main-div/index.vue';
|
import MainDiv from '@/components/main-div/index.vue';
|
||||||
import { jumpToInstall } from '@/utils/app';
|
import { jumpToInstall } from '@/utils/app';
|
||||||
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
const globalStore = GlobalStore();
|
const { globalStore, isProductPro, isOffLine } = useGlobalStore();
|
||||||
const { isProductPro } = storeToRefs(globalStore);
|
|
||||||
|
|
||||||
const mobile = computed(() => {
|
const mobile = computed(() => {
|
||||||
return globalStore.isMobile();
|
return globalStore.isMobile();
|
||||||
|
|
@ -183,7 +180,7 @@ const sync = async () => {
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let res;
|
let res;
|
||||||
if (isProductPro.value && syncCustomAppstore.value) {
|
if (isOffLine.value || (isProductPro.value && syncCustomAppstore.value)) {
|
||||||
res = await syncCutomAppStore(syncReq);
|
res = await syncCutomAppStore(syncReq);
|
||||||
} else {
|
} else {
|
||||||
res = await syncApp(syncReq);
|
res = await syncApp(syncReq);
|
||||||
|
|
@ -249,6 +246,9 @@ onMounted(async () => {
|
||||||
syncCustomAppstore.value = res.data.status === 'Enable';
|
syncCustomAppstore.value = res.data.status === 'Enable';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isOffLine.value) {
|
||||||
|
syncCustomAppstore.value = true;
|
||||||
|
}
|
||||||
mainHeight.value = window.innerHeight - 380;
|
mainHeight.value = window.innerHeight - 380;
|
||||||
window.onresize = () => {
|
window.onresize = () => {
|
||||||
return (() => {
|
return (() => {
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ import { Container } from '@/api/interface/container';
|
||||||
import CodemirrorPro from '@/components/codemirror-pro/index.vue';
|
import CodemirrorPro from '@/components/codemirror-pro/index.vue';
|
||||||
import { computeSizeFromMB } from '@/utils/util';
|
import { computeSizeFromMB } from '@/utils/util';
|
||||||
import { loadResourceLimit } from '@/api/modules/container';
|
import { loadResourceLimit } from '@/api/modules/container';
|
||||||
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
|
const { isOffLine } = useGlobalStore();
|
||||||
|
|
||||||
interface ClusterProps {
|
interface ClusterProps {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -282,6 +284,9 @@ const initForm = async (appKey: string) => {
|
||||||
formData.value.version = defaultVersion;
|
formData.value.version = defaultVersion;
|
||||||
getVersionDetail(defaultVersion);
|
getVersionDetail(defaultVersion);
|
||||||
}
|
}
|
||||||
|
if (isOffLine.value) {
|
||||||
|
formData.value.pullImage = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMasterAppInstall = async (appInstallID: number, masterNode: string) => {
|
const getMasterAppInstall = async (appInstallID: number, masterNode: string) => {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
@change="updateConfig('UpgradeBackup', config.upgradeBackup)"
|
@change="updateConfig('UpgradeBackup', config.upgradeBackup)"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<CustomSetting v-if="globalStore.isProductPro" />
|
<CustomSetting v-if="isProductPro" />
|
||||||
<span class="input-help logText" v-else>
|
<span class="input-help logText" v-else>
|
||||||
{{ $t('xpack.customApp.licenseHelper') }}
|
{{ $t('xpack.customApp.licenseHelper') }}
|
||||||
<el-link class="link" @click="toUpload" type="primary">
|
<el-link class="link" @click="toUpload" type="primary">
|
||||||
|
|
@ -58,10 +58,11 @@
|
||||||
import { getCurrentNodeCustomAppConfig } from '@/api/modules/app';
|
import { getCurrentNodeCustomAppConfig } from '@/api/modules/app';
|
||||||
import { getAppStoreConfig, updateAppStoreConfig } from '@/api/modules/setting';
|
import { getAppStoreConfig, updateAppStoreConfig } from '@/api/modules/setting';
|
||||||
import { FormRules } from 'element-plus';
|
import { FormRules } from 'element-plus';
|
||||||
import { GlobalStore } from '@/store';
|
|
||||||
import { MsgSuccess } from '@/utils/message';
|
import { MsgSuccess } from '@/utils/message';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { defineAsyncComponent } from 'vue';
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
|
const { isProductPro, isMasterProductPro } = useGlobalStore();
|
||||||
|
|
||||||
const CustomSetting = defineAsyncComponent(async () => {
|
const CustomSetting = defineAsyncComponent(async () => {
|
||||||
const modules = import.meta.glob('@/xpack/views/appstore/index.vue');
|
const modules = import.meta.glob('@/xpack/views/appstore/index.vue');
|
||||||
|
|
@ -72,7 +73,6 @@ const CustomSetting = defineAsyncComponent(async () => {
|
||||||
return { template: '<div></div>' };
|
return { template: '<div></div>' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalStore = GlobalStore();
|
|
||||||
const rules = ref<FormRules>({});
|
const rules = ref<FormRules>({});
|
||||||
const config = ref({
|
const config = ref({
|
||||||
uninstallDeleteImage: '',
|
uninstallDeleteImage: '',
|
||||||
|
|
@ -107,7 +107,7 @@ const toUpload = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNodeConfig = async () => {
|
const getNodeConfig = async () => {
|
||||||
if (globalStore.isMasterProductPro) {
|
if (isMasterProductPro.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await getCurrentNodeCustomAppConfig();
|
const res = await getCurrentNodeCustomAppConfig();
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,7 @@ const getSetting = async () => {
|
||||||
handleCommand(loginForm.language);
|
handleCommand(loginForm.language);
|
||||||
isIntl.value = res.data.isIntl;
|
isIntl.value = res.data.isIntl;
|
||||||
globalStore.isIntl = isIntl.value;
|
globalStore.isIntl = isIntl.value;
|
||||||
|
globalStore.isOffLine = res.data.isOffLine;
|
||||||
|
|
||||||
document.title = res.data.panelName;
|
document.title = res.data.panelName;
|
||||||
i18n.locale.value = res.data.language;
|
i18n.locale.value = res.data.language;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
|
const { isOffLine } = useGlobalStore();
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
|
|
@ -40,4 +42,10 @@ const buttons = [
|
||||||
path: '/settings/about',
|
path: '/settings/about',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isOffLine.value) {
|
||||||
|
buttons.splice(5, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue