mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-10-22 13:24:33 +00:00
Global and organization registries (#1672)
Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com>
This commit is contained in:
6
web/components.d.ts
vendored
6
web/components.d.ts
vendored
@@ -14,6 +14,7 @@ declare module 'vue' {
|
||||
AdminOrgsTab: typeof import('./src/components/admin/settings/AdminOrgsTab.vue')['default']
|
||||
AdminQueueStats: typeof import('./src/components/admin/settings/queue/AdminQueueStats.vue')['default']
|
||||
AdminQueueTab: typeof import('./src/components/admin/settings/AdminQueueTab.vue')['default']
|
||||
AdminRegistriesTab: typeof import('./src/components/admin/settings/AdminRegistriesTab.vue')['default']
|
||||
AdminReposTab: typeof import('./src/components/admin/settings/AdminReposTab.vue')['default']
|
||||
AdminSecretsTab: typeof import('./src/components/admin/settings/AdminSecretsTab.vue')['default']
|
||||
AdminUsersTab: typeof import('./src/components/admin/settings/AdminUsersTab.vue')['default']
|
||||
@@ -66,12 +67,12 @@ declare module 'vue' {
|
||||
IMdiPlay: typeof import('~icons/mdi/play')['default']
|
||||
IMdiRadioboxBlank: typeof import('~icons/mdi/radiobox-blank')['default']
|
||||
IMdiRadioboxIndeterminateVariant: typeof import('~icons/mdi/radiobox-indeterminate-variant')['default']
|
||||
IMdiSync: typeof import('~icons/mdi/sync')['default']
|
||||
IMdiSourceBranch: typeof import('~icons/mdi/source-branch')['default']
|
||||
IMdiSourceCommit: typeof import('~icons/mdi/source-commit')['default']
|
||||
IMdiSourceMerge: typeof import('~icons/mdi/source-merge')['default']
|
||||
IMdiSourcePull: typeof import('~icons/mdi/source-pull')['default']
|
||||
IMdiStop: typeof import('~icons/mdi/stop')['default']
|
||||
IMdiSync: typeof import('~icons/mdi/sync')['default']
|
||||
IMdiTagOutline: typeof import('~icons/mdi/tag-outline')['default']
|
||||
InputField: typeof import('./src/components/form/InputField.vue')['default']
|
||||
IPhGitlabLogoSimpleFill: typeof import('~icons/ph/gitlab-logo-simple-fill')['default']
|
||||
@@ -85,6 +86,7 @@ declare module 'vue' {
|
||||
ManualPipelinePopup: typeof import('./src/components/layout/popups/ManualPipelinePopup.vue')['default']
|
||||
Navbar: typeof import('./src/components/layout/header/Navbar.vue')['default']
|
||||
NumberField: typeof import('./src/components/form/NumberField.vue')['default']
|
||||
OrgRegistriesTab: typeof import('./src/components/org/settings/OrgRegistriesTab.vue')['default']
|
||||
OrgSecretsTab: typeof import('./src/components/org/settings/OrgSecretsTab.vue')['default']
|
||||
Panel: typeof import('./src/components/layout/Panel.vue')['default']
|
||||
PipelineFeedItem: typeof import('./src/components/pipeline-feed/PipelineFeedItem.vue')['default']
|
||||
@@ -98,7 +100,9 @@ declare module 'vue' {
|
||||
PipelineStepList: typeof import('./src/components/repo/pipeline/PipelineStepList.vue')['default']
|
||||
Popup: typeof import('./src/components/layout/Popup.vue')['default']
|
||||
RadioField: typeof import('./src/components/form/RadioField.vue')['default']
|
||||
RegistryEdit: typeof import('./src/components/registry/RegistryEdit.vue')['default']
|
||||
RegistriesTab: typeof import('./src/components/repo/settings/RegistriesTab.vue')['default']
|
||||
RegistryList: typeof import('./src/components/registry/RegistryList.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Scaffold: typeof import('./src/components/layout/scaffold/Scaffold.vue')['default']
|
||||
|
@@ -122,24 +122,6 @@
|
||||
"desc": "Enable to cancel pending and running pipelines of the same event and context before starting the newly triggered one."
|
||||
}
|
||||
},
|
||||
"registries": {
|
||||
"registries": "Registries",
|
||||
"credentials": "Registry credentials",
|
||||
"desc": "Registries credentials can be added to use private images for your pipeline.",
|
||||
"show": "Show registries",
|
||||
"add": "Add registry",
|
||||
"none": "There are no registry credentials yet.",
|
||||
"save": "Save registry",
|
||||
"created": "Registry credentials created",
|
||||
"saved": "Registry credentials saved",
|
||||
"deleted": "Registry credentials deleted",
|
||||
"address": {
|
||||
"address": "Address",
|
||||
"placeholder": "Registry Address (e.g. docker.io)"
|
||||
},
|
||||
"edit": "Edit registry",
|
||||
"delete": "Delete registry"
|
||||
},
|
||||
"crons": {
|
||||
"crons": "Crons",
|
||||
"desc": "Cron jobs can be used to trigger pipelines on a regular basis.",
|
||||
@@ -266,6 +248,9 @@
|
||||
"not_allowed": "You are not allowed to access this organization's settings",
|
||||
"secrets": {
|
||||
"desc": "Organization secrets can be passed to all organization's repository individual pipeline steps at runtime as environmental variables."
|
||||
},
|
||||
"registries": {
|
||||
"desc": "Organization registry credentials can be added to use private images for all organization's pipelines."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -276,6 +261,10 @@
|
||||
"desc": "Global secrets can be passed to all repositories individual pipeline steps at runtime as environmental variables.",
|
||||
"warning": "These secrets will be available for all server users."
|
||||
},
|
||||
"registries": {
|
||||
"desc": "Global registry credentials can be added to use private images for all server's pipelines.",
|
||||
"warning": "These registry creditentials will be available for all server users."
|
||||
},
|
||||
"agents": {
|
||||
"agents": "Agents",
|
||||
"desc": "Agents registered for this server",
|
||||
@@ -435,6 +424,26 @@
|
||||
"edit": "Edit secret",
|
||||
"delete": "Delete secret"
|
||||
},
|
||||
"registries": {
|
||||
"registries": "Registries",
|
||||
"credentials": "Registry credentials",
|
||||
"desc": "Registries credentials can be added to use private images for your pipeline.",
|
||||
"none": "There are no registry credentials yet.",
|
||||
"address": {
|
||||
"address": "Address",
|
||||
"desc": "Registry Address (e.g. docker.io)"
|
||||
},
|
||||
"show": "Show registries",
|
||||
"save": "Save registry",
|
||||
"add": "Add registry",
|
||||
"view": "View registry",
|
||||
"edit": "Edit registry",
|
||||
"delete": "Delete registry",
|
||||
"delete_confirm": "Do you really want to delete this registry?",
|
||||
"created": "Registry credentials created",
|
||||
"saved": "Registry credentials saved",
|
||||
"deleted": "Registry credentials deleted"
|
||||
},
|
||||
"default": "default",
|
||||
"info": "Info",
|
||||
"running_version": "You are running Woodpecker {0}",
|
||||
|
101
web/src/components/admin/settings/AdminRegistriesTab.vue
Normal file
101
web/src/components/admin/settings/AdminRegistriesTab.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Settings
|
||||
:title="$t('registries.registries')"
|
||||
:desc="$t('admin.settings.registries.desc')"
|
||||
docs-url="docs/usage/registries"
|
||||
:warning="$t('admin.settings.registries.warning')"
|
||||
>
|
||||
<template #titleActions>
|
||||
<Button
|
||||
v-if="selectedRegistry"
|
||||
:text="$t('registries.show')"
|
||||
start-icon="back"
|
||||
@click="selectedRegistry = undefined"
|
||||
/>
|
||||
<Button v-else :text="$t('registries.add')" start-icon="plus" @click="showAddRegistry" />
|
||||
</template>
|
||||
|
||||
<RegistryList
|
||||
v-if="!selectedRegistry"
|
||||
v-model="registries"
|
||||
:is-deleting="isDeleting"
|
||||
@edit="editRegistry"
|
||||
@delete="deleteRegistry"
|
||||
/>
|
||||
|
||||
<RegistryEdit
|
||||
v-else
|
||||
v-model="selectedRegistry"
|
||||
:is-saving="isSaving"
|
||||
@save="createRegistry"
|
||||
@cancel="selectedRegistry = undefined"
|
||||
/>
|
||||
</Settings>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import Settings from '~/components/layout/Settings.vue';
|
||||
import RegistryEdit from '~/components/registry/RegistryEdit.vue';
|
||||
import RegistryList from '~/components/registry/RegistryList.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { useAsyncAction } from '~/compositions/useAsyncAction';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { usePagination } from '~/compositions/usePaginate';
|
||||
import type { Registry } from '~/lib/api/types';
|
||||
|
||||
const emptyRegistry: Partial<Registry> = {
|
||||
address: '',
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const i18n = useI18n();
|
||||
|
||||
const selectedRegistry = ref<Partial<Registry>>();
|
||||
const isEditingRegistry = computed(() => !!selectedRegistry.value?.id);
|
||||
|
||||
async function loadRegistries(page: number): Promise<Registry[] | null> {
|
||||
return apiClient.getGlobalRegistryList({ page });
|
||||
}
|
||||
|
||||
const { resetPage, data: registries } = usePagination(loadRegistries, () => !selectedRegistry.value);
|
||||
|
||||
const { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!selectedRegistry.value) {
|
||||
throw new Error("Unexpected: Can't get registry");
|
||||
}
|
||||
|
||||
if (isEditingRegistry.value) {
|
||||
await apiClient.updateGlobalRegistry(selectedRegistry.value);
|
||||
} else {
|
||||
await apiClient.createGlobalRegistry(selectedRegistry.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: isEditingRegistry.value ? i18n.t('registries.saved') : i18n.t('registries.created'),
|
||||
type: 'success',
|
||||
});
|
||||
selectedRegistry.value = undefined;
|
||||
resetPage();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {
|
||||
await apiClient.deleteGlobalRegistry(_registry.address);
|
||||
notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
});
|
||||
|
||||
function editRegistry(registry: Registry) {
|
||||
selectedRegistry.value = cloneDeep(registry);
|
||||
}
|
||||
|
||||
function showAddRegistry() {
|
||||
selectedRegistry.value = cloneDeep(emptyRegistry);
|
||||
}
|
||||
</script>
|
@@ -3,7 +3,7 @@
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="w-full py-1 md:py-2 md:w-auto md:px-8 flex cursor-pointer md:border-b-2 text-wp-text-100 hover:text-wp-text-200 items-center"
|
||||
class="w-full py-1 md:py-2 md:w-auto md:px-6 flex cursor-pointer md:border-b-2 text-wp-text-100 hover:text-wp-text-200 items-center"
|
||||
:class="{
|
||||
'border-wp-text-100': activeTab === tab.id,
|
||||
'border-transparent': activeTab !== tab.id,
|
||||
|
113
web/src/components/org/settings/OrgRegistriesTab.vue
Normal file
113
web/src/components/org/settings/OrgRegistriesTab.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<Settings
|
||||
:title="$t('registries.registries')"
|
||||
:desc="$t('org.settings.registries.desc')"
|
||||
docs-url="docs/usage/registries"
|
||||
>
|
||||
<template #titleActions>
|
||||
<Button
|
||||
v-if="selectedRegistry"
|
||||
:text="$t('registries.show')"
|
||||
start-icon="back"
|
||||
@click="selectedRegistry = undefined"
|
||||
/>
|
||||
<Button v-else :text="$t('registries.add')" start-icon="plus" @click="showAddRegistry" />
|
||||
</template>
|
||||
|
||||
<RegistryList
|
||||
v-if="!selectedRegistry"
|
||||
v-model="registries"
|
||||
:is-deleting="isDeleting"
|
||||
@edit="editRegistry"
|
||||
@delete="deleteRegistry"
|
||||
/>
|
||||
|
||||
<RegistryEdit
|
||||
v-else
|
||||
v-model="selectedRegistry"
|
||||
:is-saving="isSaving"
|
||||
@save="createRegistry"
|
||||
@cancel="selectedRegistry = undefined"
|
||||
/>
|
||||
</Settings>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { computed, inject, ref, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import Settings from '~/components/layout/Settings.vue';
|
||||
import RegistryEdit from '~/components/registry/RegistryEdit.vue';
|
||||
import RegistryList from '~/components/registry/RegistryList.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { useAsyncAction } from '~/compositions/useAsyncAction';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { usePagination } from '~/compositions/usePaginate';
|
||||
import type { Org, Registry } from '~/lib/api/types';
|
||||
|
||||
const emptyRegistry: Partial<Registry> = {
|
||||
address: '',
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const i18n = useI18n();
|
||||
|
||||
const org = inject<Ref<Org>>('org');
|
||||
const selectedRegistry = ref<Partial<Registry>>();
|
||||
const isEditing = computed(() => !!selectedRegistry.value?.id);
|
||||
|
||||
async function loadRegistries(page: number): Promise<Registry[] | null> {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
return apiClient.getOrgRegistryList(org.value.id, { page });
|
||||
}
|
||||
|
||||
const { resetPage, data: registries } = usePagination(loadRegistries, () => !selectedRegistry.value);
|
||||
|
||||
const { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
if (!selectedRegistry.value) {
|
||||
throw new Error("Unexpected: Can't get registry");
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
await apiClient.updateOrgRegistry(org.value.id, selectedRegistry.value);
|
||||
} else {
|
||||
await apiClient.createOrgRegistry(org.value.id, selectedRegistry.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: isEditing.value ? i18n.t('registries.saved') : i18n.t('registries.created'),
|
||||
type: 'success',
|
||||
});
|
||||
selectedRegistry.value = undefined;
|
||||
resetPage();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
await apiClient.deleteOrgRegistry(org.value.id, _registry.address);
|
||||
notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
});
|
||||
|
||||
function editRegistry(registry: Registry) {
|
||||
selectedRegistry.value = cloneDeep(registry);
|
||||
}
|
||||
|
||||
function showAddRegistry() {
|
||||
selectedRegistry.value = cloneDeep(emptyRegistry);
|
||||
}
|
||||
</script>
|
78
web/src/components/registry/RegistryEdit.vue
Normal file
78
web/src/components/registry/RegistryEdit.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div v-if="innerValue" class="space-y-4">
|
||||
<form @submit.prevent="save">
|
||||
<InputField v-slot="{ id }" :label="$t('registries.address.address')">
|
||||
<!-- TODO: check input field Address is a valid address -->
|
||||
<TextField
|
||||
:id="id"
|
||||
v-model="innerValue.address"
|
||||
:placeholder="$t('registries.address.desc')"
|
||||
required
|
||||
:disabled="isEditing || isReadOnly"
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<InputField v-slot="{ id }" :label="$t('username')">
|
||||
<TextField
|
||||
:id="id"
|
||||
v-model="innerValue.username"
|
||||
:placeholder="$t('username')"
|
||||
required
|
||||
:disabled="isReadOnly"
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<InputField v-if="!isReadOnly" v-slot="{ id }" :label="$t('password')">
|
||||
<TextField :id="id" v-model="innerValue.password" :placeholder="$t('password')" :required="!isEditing" />
|
||||
</InputField>
|
||||
|
||||
<div v-if="!isReadOnly" class="flex gap-2">
|
||||
<Button type="button" color="gray" :text="$t('cancel')" @click="$emit('cancel')" />
|
||||
<Button
|
||||
type="submit"
|
||||
color="green"
|
||||
:is-loading="isSaving"
|
||||
:text="isEditing ? $t('registries.save') : $t('registries.add')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import InputField from '~/components/form/InputField.vue';
|
||||
import TextField from '~/components/form/TextField.vue';
|
||||
import type { Registry } from '~/lib/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Partial<Registry>;
|
||||
isSaving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: Partial<Registry> | undefined): void;
|
||||
(event: 'save', value: Partial<Registry>): void;
|
||||
(event: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const modelValue = toRef(props, 'modelValue');
|
||||
const innerValue = computed({
|
||||
get: () => modelValue.value,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const isEditing = computed(() => !!innerValue.value?.id);
|
||||
const isReadOnly = computed(() => !!innerValue.value?.readonly);
|
||||
|
||||
function save() {
|
||||
if (!innerValue.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('save', innerValue.value);
|
||||
}
|
||||
</script>
|
63
web/src/components/registry/RegistryList.vue
Normal file
63
web/src/components/registry/RegistryList.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="space-y-4 text-wp-text-100">
|
||||
<ListItem
|
||||
v-for="registry in registries"
|
||||
:key="registry.id"
|
||||
class="items-center !bg-wp-background-200 !dark:bg-wp-background-100"
|
||||
>
|
||||
<span>{{ registry.address }}</span>
|
||||
<IconButton
|
||||
:icon="registry.readonly ? 'chevron-right' : 'edit'"
|
||||
class="ml-auto w-8 h-8"
|
||||
:title="registry.readonly ? $t('registries.view') : $t('registries.edit')"
|
||||
@click="editRegistry(registry)"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="!registry.readonly"
|
||||
icon="trash"
|
||||
class="w-8 h-8 hover:text-wp-control-error-100"
|
||||
:is-loading="isDeleting"
|
||||
:title="$t('registries.delete')"
|
||||
@click="deleteRegistry(registry)"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<div v-if="registries?.length === 0" class="ml-2">{{ $t('registries.none') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import IconButton from '~/components/atomic/IconButton.vue';
|
||||
import ListItem from '~/components/atomic/ListItem.vue';
|
||||
import type { Registry } from '~/lib/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: (Registry & { edit?: boolean })[];
|
||||
isDeleting: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'edit', registry: Registry): void;
|
||||
(event: 'delete', registry: Registry): void;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const registries = toRef(props, 'modelValue');
|
||||
|
||||
function editRegistry(registry: Registry) {
|
||||
emit('edit', registry);
|
||||
}
|
||||
|
||||
function deleteRegistry(registry: Registry) {
|
||||
// TODO: use proper dialog
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!confirm(i18n.t('registries.delete_confirm'))) {
|
||||
return;
|
||||
}
|
||||
emit('delete', registry);
|
||||
}
|
||||
</script>
|
@@ -1,95 +1,54 @@
|
||||
<template>
|
||||
<Settings
|
||||
:title="$t('repo.settings.registries.credentials')"
|
||||
:desc="$t('repo.settings.registries.desc')"
|
||||
docs-url="docs/usage/registries"
|
||||
>
|
||||
<Settings :title="$t('registries.credentials')" :desc="$t('registries.desc')" docs-url="docs/usage/registries">
|
||||
<template #titleActions>
|
||||
<Button
|
||||
v-if="selectedRegistry"
|
||||
:text="$t('registries.show')"
|
||||
start-icon="back"
|
||||
:text="$t('repo.settings.registries.show')"
|
||||
@click="selectedRegistry = undefined"
|
||||
/>
|
||||
<Button v-else start-icon="plus" :text="$t('repo.settings.registries.add')" @click="selectedRegistry = {}" />
|
||||
<Button v-else :text="$t('registries.add')" start-icon="plus" @click="showAddRegistry" />
|
||||
</template>
|
||||
|
||||
<div v-if="!selectedRegistry" class="space-y-4 text-wp-text-100">
|
||||
<ListItem
|
||||
v-for="registry in registries"
|
||||
:key="registry.id"
|
||||
class="items-center !bg-wp-background-200 !dark:bg-wp-background-100"
|
||||
>
|
||||
<span>{{ registry.address }}</span>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
class="ml-auto w-8 h-8"
|
||||
:title="$t('repo.settings.registries.edit')"
|
||||
@click="selectedRegistry = registry"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
class="w-8 h-8 hover:text-wp-control-error-100"
|
||||
:is-loading="isDeleting"
|
||||
:title="$t('repo.settings.registries.delete')"
|
||||
@click="deleteRegistry(registry)"
|
||||
/>
|
||||
</ListItem>
|
||||
<RegistryList
|
||||
v-if="!selectedRegistry"
|
||||
v-model="registries"
|
||||
:is-deleting="isDeleting"
|
||||
@edit="editRegistry"
|
||||
@delete="deleteRegistry"
|
||||
/>
|
||||
|
||||
<div v-if="registries?.length === 0" class="ml-2">{{ $t('repo.settings.registries.none') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<form @submit.prevent="createRegistry">
|
||||
<InputField v-slot="{ id }" :label="$t('repo.settings.registries.address.address')">
|
||||
<!-- TODO: check input field Address is a valid address -->
|
||||
<TextField
|
||||
:id="id"
|
||||
v-model="selectedRegistry.address"
|
||||
:placeholder="$t('repo.settings.registries.address.placeholder')"
|
||||
required
|
||||
:disabled="isEditingRegistry"
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<InputField v-slot="{ id }" :label="$t('username')">
|
||||
<TextField :id="id" v-model="selectedRegistry.username" :placeholder="$t('username')" required />
|
||||
</InputField>
|
||||
|
||||
<InputField v-slot="{ id }" :label="$t('password')">
|
||||
<TextField :id="id" v-model="selectedRegistry.password" :placeholder="$t('password')" required />
|
||||
</InputField>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" color="gray" :text="$t('cancel')" @click="selectedRegistry = undefined" />
|
||||
<Button
|
||||
type="submit"
|
||||
color="green"
|
||||
:is-loading="isSaving"
|
||||
:text="isEditingRegistry ? $t('repo.settings.registries.save') : $t('repo.settings.registries.add')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<RegistryEdit
|
||||
v-else
|
||||
v-model="selectedRegistry"
|
||||
:is-saving="isSaving"
|
||||
@save="createRegistry"
|
||||
@cancel="selectedRegistry = undefined"
|
||||
/>
|
||||
</Settings>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { computed, inject, ref, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import IconButton from '~/components/atomic/IconButton.vue';
|
||||
import ListItem from '~/components/atomic/ListItem.vue';
|
||||
import InputField from '~/components/form/InputField.vue';
|
||||
import TextField from '~/components/form/TextField.vue';
|
||||
import Settings from '~/components/layout/Settings.vue';
|
||||
import RegistryEdit from '~/components/registry/RegistryEdit.vue';
|
||||
import RegistryList from '~/components/registry/RegistryList.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { useAsyncAction } from '~/compositions/useAsyncAction';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { usePagination } from '~/compositions/usePaginate';
|
||||
import type { Registry, Repo } from '~/lib/api/types';
|
||||
|
||||
const emptyRegistry: Partial<Registry> = {
|
||||
address: '',
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const i18n = useI18n();
|
||||
@@ -123,9 +82,7 @@ const { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async (
|
||||
await apiClient.createRegistry(repo.value.id, selectedRegistry.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: isEditingRegistry.value
|
||||
? i18n.t('repo.settings.registries.saved')
|
||||
: i18n.t('repo.settings.registries.created'),
|
||||
title: isEditingRegistry.value ? i18n.t('registries.saved') : i18n.t('registries.created'),
|
||||
type: 'success',
|
||||
});
|
||||
selectedRegistry.value = undefined;
|
||||
@@ -139,7 +96,15 @@ const { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async
|
||||
|
||||
const registryAddress = encodeURIComponent(_registry.address);
|
||||
await apiClient.deleteRegistry(repo.value.id, registryAddress);
|
||||
notifications.notify({ title: i18n.t('repo.settings.registries.deleted'), type: 'success' });
|
||||
notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
});
|
||||
|
||||
function editRegistry(registry: Registry) {
|
||||
selectedRegistry.value = cloneDeep(registry);
|
||||
}
|
||||
|
||||
function showAddRegistry() {
|
||||
selectedRegistry.value = cloneDeep(emptyRegistry);
|
||||
}
|
||||
</script>
|
||||
|
@@ -170,19 +170,53 @@ export default class WoodpeckerClient extends ApiClient {
|
||||
|
||||
getRegistryList(repoId: number, opts?: PaginationOptions): Promise<Registry[] | null> {
|
||||
const query = encodeQueryString(opts);
|
||||
return this._get(`/api/repos/${repoId}/registry?${query}`) as Promise<Registry[] | null>;
|
||||
return this._get(`/api/repos/${repoId}/registries?${query}`) as Promise<Registry[] | null>;
|
||||
}
|
||||
|
||||
createRegistry(repoId: number, registry: Partial<Registry>): Promise<unknown> {
|
||||
return this._post(`/api/repos/${repoId}/registry`, registry);
|
||||
return this._post(`/api/repos/${repoId}/registries`, registry);
|
||||
}
|
||||
|
||||
updateRegistry(repoId: number, registry: Partial<Registry>): Promise<unknown> {
|
||||
return this._patch(`/api/repos/${repoId}/registry/${registry.address}`, registry);
|
||||
return this._patch(`/api/repos/${repoId}/registries/${registry.address}`, registry);
|
||||
}
|
||||
|
||||
deleteRegistry(repoId: number, registryAddress: string): Promise<unknown> {
|
||||
return this._delete(`/api/repos/${repoId}/registry/${registryAddress}`);
|
||||
return this._delete(`/api/repos/${repoId}/registries/${registryAddress}`);
|
||||
}
|
||||
|
||||
getOrgRegistryList(orgId: number, opts?: PaginationOptions): Promise<Registry[] | null> {
|
||||
const query = encodeQueryString(opts);
|
||||
return this._get(`/api/orgs/${orgId}/registries?${query}`) as Promise<Registry[] | null>;
|
||||
}
|
||||
|
||||
createOrgRegistry(orgId: number, registry: Partial<Registry>): Promise<unknown> {
|
||||
return this._post(`/api/orgs/${orgId}/registries`, registry);
|
||||
}
|
||||
|
||||
updateOrgRegistry(orgId: number, registry: Partial<Registry>): Promise<unknown> {
|
||||
return this._patch(`/api/orgs/${orgId}/registries/${registry.address}`, registry);
|
||||
}
|
||||
|
||||
deleteOrgRegistry(orgId: number, registryAddress: string): Promise<unknown> {
|
||||
return this._delete(`/api/orgs/${orgId}/registries/${registryAddress}`);
|
||||
}
|
||||
|
||||
getGlobalRegistryList(opts?: PaginationOptions): Promise<Registry[] | null> {
|
||||
const query = encodeQueryString(opts);
|
||||
return this._get(`/api/registries?${query}`) as Promise<Registry[] | null>;
|
||||
}
|
||||
|
||||
createGlobalRegistry(registry: Partial<Registry>): Promise<unknown> {
|
||||
return this._post(`/api/registries`, registry);
|
||||
}
|
||||
|
||||
updateGlobalRegistry(registry: Partial<Registry>): Promise<unknown> {
|
||||
return this._patch(`/api/registries/${registry.address}`, registry);
|
||||
}
|
||||
|
||||
deleteGlobalRegistry(registryAddress: string): Promise<unknown> {
|
||||
return this._delete(`/api/registries/${registryAddress}`);
|
||||
}
|
||||
|
||||
getCronList(repoId: number, opts?: PaginationOptions): Promise<Cron[] | null> {
|
||||
|
@@ -1,6 +1,9 @@
|
||||
export interface Registry {
|
||||
id: string;
|
||||
repo_id: number;
|
||||
org_id: number;
|
||||
address: string;
|
||||
username: string;
|
||||
password: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
@@ -9,6 +9,9 @@
|
||||
<Tab id="secrets" :title="$t('secrets.secrets')">
|
||||
<AdminSecretsTab />
|
||||
</Tab>
|
||||
<Tab id="registries" :title="$t('registries.registries')">
|
||||
<AdminRegistriesTab />
|
||||
</Tab>
|
||||
<Tab id="repos" :title="$t('admin.settings.repos.repos')">
|
||||
<AdminReposTab />
|
||||
</Tab>
|
||||
@@ -36,6 +39,7 @@ import AdminAgentsTab from '~/components/admin/settings/AdminAgentsTab.vue';
|
||||
import AdminInfoTab from '~/components/admin/settings/AdminInfoTab.vue';
|
||||
import AdminOrgsTab from '~/components/admin/settings/AdminOrgsTab.vue';
|
||||
import AdminQueueTab from '~/components/admin/settings/AdminQueueTab.vue';
|
||||
import AdminRegistriesTab from '~/components/admin/settings/AdminRegistriesTab.vue';
|
||||
import AdminReposTab from '~/components/admin/settings/AdminReposTab.vue';
|
||||
import AdminSecretsTab from '~/components/admin/settings/AdminSecretsTab.vue';
|
||||
import AdminUsersTab from '~/components/admin/settings/AdminUsersTab.vue';
|
||||
|
@@ -14,6 +14,10 @@
|
||||
<Tab id="secrets" :title="$t('secrets.secrets')">
|
||||
<OrgSecretsTab />
|
||||
</Tab>
|
||||
|
||||
<Tab id="registries" :title="$t('registries.registries')">
|
||||
<OrgRegistriesTab />
|
||||
</Tab>
|
||||
</Scaffold>
|
||||
</template>
|
||||
|
||||
@@ -23,6 +27,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Tab from '~/components/layout/scaffold/Tab.vue';
|
||||
import OrgRegistriesTab from '~/components/org/settings/OrgRegistriesTab.vue';
|
||||
import OrgSecretsTab from '~/components/org/settings/OrgSecretsTab.vue';
|
||||
import { inject } from '~/compositions/useInjectProvide';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
|
@@ -2,15 +2,15 @@
|
||||
<Scaffold enable-tabs :go-back="goBack">
|
||||
<template #title>
|
||||
<span>
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo!.org_id } }" class="hover:underline">
|
||||
{{ repo!.owner }}
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo!.org_id } }" class="hover:underline">{{
|
||||
repo!.owner
|
||||
/* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */
|
||||
}}</router-link>
|
||||
/
|
||||
<router-link :to="{ name: 'repo' }" class="hover:underline">
|
||||
{{ repo!.name }}
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'repo' }" class="hover:underline">{{
|
||||
repo!.name
|
||||
/* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */
|
||||
}}</router-link>
|
||||
/
|
||||
{{ $t('settings') }}
|
||||
</span>
|
||||
@@ -22,7 +22,7 @@
|
||||
<Tab id="secrets" :title="$t('secrets.secrets')">
|
||||
<SecretsTab />
|
||||
</Tab>
|
||||
<Tab id="registries" :title="$t('repo.settings.registries.registries')">
|
||||
<Tab id="registries" :title="$t('registries.registries')">
|
||||
<RegistriesTab />
|
||||
</Tab>
|
||||
<Tab id="crons" :title="$t('repo.settings.crons.crons')">
|
||||
|
@@ -9,8 +9,10 @@
|
||||
<span class="flex">
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">{{
|
||||
repo.owner
|
||||
/* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */
|
||||
}}</router-link>
|
||||
{{ ` / ${repo.name}` }}
|
||||
/
|
||||
{{ repo.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template #titleActions>
|
||||
|
@@ -10,14 +10,12 @@
|
||||
>
|
||||
<template #title>
|
||||
<span>
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">
|
||||
{{ repo.owner }}
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">{{
|
||||
repo.owner
|
||||
/* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */
|
||||
}}</router-link>
|
||||
/
|
||||
<router-link :to="{ name: 'repo' }" class="hover:underline">
|
||||
{{ repo.name }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'repo' }" class="hover:underline">{{ repo.name }}</router-link>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
Reference in New Issue
Block a user