diff --git a/package-lock.json b/package-lock.json index 9b3f19d..5530e1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docs", - "version": "0.6.4", + "version": "0.6.4-content", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docs", - "version": "0.6.4", + "version": "0.6.4-content", "license": "MIT", "dependencies": { "@beeline/design-tokens": "^1.31.0", diff --git a/src/.vitepress/config.mts b/src/.vitepress/config.mts index ed85325..39178fb 100644 --- a/src/.vitepress/config.mts +++ b/src/.vitepress/config.mts @@ -1,6 +1,8 @@ import { defineConfig } from 'vitepress' import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs' import { overrideComponents } from './override-components' +import { autoSectionLinksPlugin } from './plugins/auto-section-links' +import { resolve } from 'path' const gitlab = ` const new_version = process.env?.VITE_NEW_VERSION; console.log({ base: typeof new_version !== 'undefined' ? '/' : '/docs/' }) - -// https://vitepress.dev/reference/site-config -export default defineConfig({ - srcDir: ".", - title: " ", - description: "Документация Beeline Cloud", - head: [['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/bee-favicon.png' }]], - base: typeof new_version !== 'undefined' ? '/' : '/docs/', - markdown: { - config(md) { - md.use(tabsMarkdownPlugin) - } - }, - vite: { - resolve: { - alias: overrideComponents(), - } - }, - locales: { - root: { - label: 'Русский', - lang: 'ru', - } - }, - themeConfig: { - logo: { - light: '/img/logo-cloud.svg', - dark: '/img/logo-cloud.svg', - alt: '', - }, - search: { - provider: 'local', - options: { - locales: { - root: { - translations: { - button: { - buttonText: 'Поиск', - buttonAriaLabel: 'Поиск' - }, - modal: { - noResultsText: 'По вашему запросу ничего не найдено', - resetButtonTitle: 'Сбросить', - } - } - } - } - } - }, - // https://vitepress.dev/reference/default-theme-config - // nav: [ - // { - // text: 'Документация', - // link: '/guide/', - // }, - // { - // text: 'API', - // link: '', - // }, - // { - // text: 'Terraform', - // // link: '/terraform/', - // link: '', - // }, - // ], - - docFooter: { - next: 'Вперед', - prev: 'Назад' - }, - - outline: { - label: 'Содержание' - }, - sidebar: { +const sidebarConfig = { '/platform/': [ { text: 'Платформа Beeline Cloud', link: '/platform/index.md', @@ -140,11 +68,10 @@ export default defineConfig({ text: 'Резервное копирование', link: '/backups/index.md', }, { - text: 'Обзор сервиса', link: '/backups/backups-overview.md', + text: 'Обзор сервиса', link: '/backups/about.md', collapsed: true, items: [ - {text: 'О сервисе', link: '/backups/about.md'}, - {text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'}, + {text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'}, ] }, {text: 'Резервное копирование виртуальных машин Beeline Cloud', link: '/backups/backup-internal-infra.md'}, @@ -216,7 +143,6 @@ export default defineConfig({ { text: 'Настройка site-to-site подключения с помощью IPSec', link: '/vdc/vdc-how-to/networks/how-to-setup-ipsec-vpn.md', collapsed: true, items: [ - {text: 'Настройка IPSec VPN', link: '/vdc/vdc-how-to/networks/ipsec/setup-ipsec-vpn.md'}, {text: 'Настройка ASAv', link: '/vdc/vdc-how-to/networks/ipsec/asav.md'}, {text: 'Настройка CSR 1000v', link: '/vdc/vdc-how-to/networks/ipsec/csr1000v.md'}, {text: 'Настройка Fortigate', link: '/vdc/vdc-how-to/networks/ipsec/fortigate.md'}, @@ -321,7 +247,85 @@ export default defineConfig({ text: 'Подключение к виртуальному рабочему месту', link: '/vdi/vdi-how-to/vdi-connect.md' }, ], - }, + } + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + srcDir: ".", + title: " ", + description: "Документация Beeline Cloud", + head: [['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/bee-favicon.png' }]], + base: typeof new_version !== 'undefined' ? '/' : '/docs/', + markdown: { + config(md) { + md.use(tabsMarkdownPlugin) + } + }, + vite: { + resolve: { + alias: overrideComponents(), + }, + plugins: [ + autoSectionLinksPlugin(resolve(__dirname, '..'), sidebarConfig) as any, + ] + }, + locales: { + root: { + label: 'Русский', + lang: 'ru', + } + }, + themeConfig: { + logo: { + light: '/img/logo-cloud.svg', + dark: '/img/logo-cloud.svg', + alt: '', + }, + search: { + provider: 'local', + options: { + locales: { + root: { + translations: { + button: { + buttonText: 'Поиск', + buttonAriaLabel: 'Поиск' + }, + modal: { + noResultsText: 'По вашему запросу ничего не найдено', + resetButtonTitle: 'Сбросить', + } + } + } + } + } + }, + // https://vitepress.dev/reference/default-theme-config + // nav: [ + // { + // text: 'Документация', + // link: '/guide/', + // }, + // { + // text: 'API', + // link: '', + // }, + // { + // text: 'Terraform', + // // link: '/terraform/', + // link: '', + // }, + // ], + + docFooter: { + next: 'Вперед', + prev: 'Назад' + }, + + outline: { + label: 'Содержание' + }, + sidebar: sidebarConfig, }, } ) diff --git a/src/.vitepress/plugins/auto-section-links.ts b/src/.vitepress/plugins/auto-section-links.ts new file mode 100644 index 0000000..5192765 --- /dev/null +++ b/src/.vitepress/plugins/auto-section-links.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'vite' +import type { SidebarItem } from './utils/types' +import { processAllFiles } from './hook/process-files' + +export const autoSectionLinksPlugin = ( + srcDir: string, + sidebarConfig: Record +): Plugin => ({ + name: 'auto-section-links', + buildStart: () => { + processAllFiles(srcDir, sidebarConfig) + }, + configureServer: () => { + processAllFiles(srcDir, sidebarConfig) + } +}) diff --git a/src/.vitepress/plugins/constants.ts b/src/.vitepress/plugins/constants.ts new file mode 100644 index 0000000..d0b819d --- /dev/null +++ b/src/.vitepress/plugins/constants.ts @@ -0,0 +1,4 @@ +export const INDEX_FILE = 'index.md' +export const INDEX_FILE_PATTERN = /-index\.md$|index\.md$/ +export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---/ +export const SECTION_LINK_KEYS = ['title', 'link', 'description'] as const diff --git a/src/.vitepress/plugins/hook/process-files.ts b/src/.vitepress/plugins/hook/process-files.ts new file mode 100644 index 0000000..d08f318 --- /dev/null +++ b/src/.vitepress/plugins/hook/process-files.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'fs' +import { relative, resolve } from 'path' +import type { SidebarItem } from '../utils/types' +import { findMarkdownFiles } from '../utils/file-finder' +import { processIndexFile, processPageWithItems } from '../utils/file-processor' +import { INDEX_FILE, INDEX_FILE_PATTERN } from '../constants' + +export const processAllFiles = ( + srcDir: string, + sidebarConfig: Record +) => { + const srcPath = resolve(srcDir) + + for (const [folderPath, items] of Object.entries(sidebarConfig)) { + const normalizedFolder = folderPath.replace(/^\/+|\/+$/g, '') + const folderFullPath = resolve(srcPath, normalizedFolder) + + if (!existsSync(folderFullPath)) { + continue + } + + const mdFiles = findMarkdownFiles(folderFullPath) + + for (const filePath of mdFiles) { + const fileName = filePath.split(/[/\\]/).pop() || '' + const relativePath = relative(srcPath, filePath).replace(/\\/g, '/') + const normalizedPath = relativePath.startsWith('/') ? relativePath : `/${relativePath}` + + if (fileName === INDEX_FILE) { + processIndexFile(filePath, items, srcPath) + } else if (INDEX_FILE_PATTERN.test(fileName) && fileName !== INDEX_FILE) { + processPageWithItems(filePath, items, srcPath) + } + } + } +} diff --git a/src/.vitepress/plugins/utils/descriptions.ts b/src/.vitepress/plugins/utils/descriptions.ts new file mode 100644 index 0000000..ce1bc74 --- /dev/null +++ b/src/.vitepress/plugins/utils/descriptions.ts @@ -0,0 +1,17 @@ +import { readFileSync, existsSync } from 'fs' +import { resolve } from 'path' +import { parseFrontmatter } from './frontmatter' + +export const getPageDescription = (filePath: string, srcDir: string) => { + try { + const fullPath = resolve(srcDir, filePath.replace(/^\//, '')) + if (!existsSync(fullPath)) return undefined + + const fileContent = readFileSync(fullPath, 'utf-8') + const { frontmatter } = parseFrontmatter(fileContent) + + return frontmatter.description as string | undefined + } catch { + return undefined + } +} diff --git a/src/.vitepress/plugins/utils/file-finder.ts b/src/.vitepress/plugins/utils/file-finder.ts new file mode 100644 index 0000000..c9918e4 --- /dev/null +++ b/src/.vitepress/plugins/utils/file-finder.ts @@ -0,0 +1,24 @@ +import { readdirSync, statSync } from 'fs' +import { join } from 'path' + +export const findMarkdownFiles = (dir: string) => { + const files: string[] = [] + + try { + const entries = readdirSync(dir) + + for (const entry of entries) { + const fullPath = join(dir, entry) + const stat = statSync(fullPath) + + if (stat.isDirectory()) { + files.push(...findMarkdownFiles(fullPath)) + } else if (entry.endsWith('.md')) { + files.push(fullPath) + } + } + } catch { + } + + return files +} diff --git a/src/.vitepress/plugins/utils/file-processor.ts b/src/.vitepress/plugins/utils/file-processor.ts new file mode 100644 index 0000000..d85b2c6 --- /dev/null +++ b/src/.vitepress/plugins/utils/file-processor.ts @@ -0,0 +1,135 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { relative, resolve } from 'path' +import type { SidebarItem } from './types' +import { parseFrontmatter, stringifyFrontmatter } from './frontmatter' +import { mergeSectionLinks, extractTopLevelLinks, extractItemsForPage } from './links' +import { normalizeLink } from './path-utils' +import { SectionLinkListItem } from '../../theme/components/SectionLinkList/SectionLinkList.types' + +const removeDuplicates = (links: SectionLinkListItem[]) => { + const result: SectionLinkListItem[] = [] + const seenLinks = new Set() + + for (const link of links) { + if (link.link) { + const normalized = normalizeLink(link.link) + if (!seenLinks.has(normalized)) { + seenLinks.add(normalized) + result.push(link) + } + } + } + + return result +} + +const hasChanges = ( + existingLinks: SectionLinkListItem[], + mergedLinks: SectionLinkListItem[], + newLinks: SectionLinkListItem[] +) => { + const existingLinksSet = new Set( + existingLinks + .map(link => link.link) + .filter((link): link is string => Boolean(link)) + .map(normalizeLink) + ) + + const hasNewLinks = newLinks.some( + link => link.link && !existingLinksSet.has(normalizeLink(link.link)) + ) + + if (hasNewLinks) return true + + const sidebarLinksSet = new Set( + newLinks + .map(link => link.link) + .filter((link): link is string => Boolean(link)) + .map(normalizeLink) + ) + + const existingSidebarLinks = existingLinks + .filter(link => link.link && sidebarLinksSet.has(normalizeLink(link.link))) + .map(link => normalizeLink(link.link!)) + .join('|') + + const mergedSidebarLinks = mergedLinks + .slice(0, newLinks.length) + .filter(link => link.link && sidebarLinksSet.has(normalizeLink(link.link))) + .map(link => normalizeLink(link.link!)) + .join('|') + + return existingSidebarLinks !== mergedSidebarLinks && mergedLinks.length === existingLinks.length +} + +export const processIndexFile = ( + filePath: string, + sidebarItems: SidebarItem[], + srcDir: string +) => { + if (!existsSync(filePath)) return false + + try { + const fileContent = readFileSync(filePath, 'utf-8') + const { frontmatter, content } = parseFrontmatter(fileContent) + + const relativePath = relative(srcDir, filePath).replace(/\\/g, '/') + const normalizedPath = relativePath.startsWith('/') ? relativePath : `/${relativePath}` + + const rawExistingLinks: SectionLinkListItem[] = Array.isArray(frontmatter.section_links) + ? [...frontmatter.section_links] + : [] + + const existingLinks = removeDuplicates(rawExistingLinks) + const newLinks = extractTopLevelLinks(sidebarItems, normalizedPath, srcDir) + + if (!newLinks.length) return false + + const mergedLinks = removeDuplicates(mergeSectionLinks(existingLinks, newLinks)) + + if (!hasChanges(existingLinks, mergedLinks, newLinks)) return false + + frontmatter.section_links = mergedLinks + const updatedContent = stringifyFrontmatter(frontmatter, content) + writeFileSync(filePath, updatedContent, 'utf-8') + return true + } catch { + return false + } +} + +export const processPageWithItems = ( + filePath: string, + sidebarItems: SidebarItem[], + srcDir: string +) => { + if (!existsSync(filePath)) return false + + try { + const fileContent = readFileSync(filePath, 'utf-8') + const { frontmatter, content } = parseFrontmatter(fileContent) + + const relativePath = relative(srcDir, filePath).replace(/\\/g, '/') + const normalizedPath = relativePath.startsWith('/') ? relativePath : `/${relativePath}` + + const rawExistingLinks: SectionLinkListItem[] = Array.isArray(frontmatter.section_links) + ? [...frontmatter.section_links] + : [] + + const existingLinks = removeDuplicates(rawExistingLinks) + const newLinks = extractItemsForPage(sidebarItems, normalizedPath, srcDir) + + if (!newLinks.length) return false + + const mergedLinks = removeDuplicates(mergeSectionLinks(existingLinks, newLinks)) + + if (!hasChanges(existingLinks, mergedLinks, newLinks)) return false + + frontmatter.section_links = mergedLinks + const updatedContent = stringifyFrontmatter(frontmatter, content) + writeFileSync(filePath, updatedContent, 'utf-8') + return true + } catch { + return false + } +} diff --git a/src/.vitepress/plugins/utils/frontmatter.ts b/src/.vitepress/plugins/utils/frontmatter.ts new file mode 100644 index 0000000..e43f530 --- /dev/null +++ b/src/.vitepress/plugins/utils/frontmatter.ts @@ -0,0 +1,126 @@ +import type { Frontmatter } from './types' +import { SECTION_LINK_KEYS, FRONTMATTER_REGEX } from '../constants' +import { SectionLinkListItem } from '../../theme/components/SectionLinkList/SectionLinkList.types' + +export const parseFrontmatter = (content: string) => { + const frontmatterMatch = content.match(FRONTMATTER_REGEX) + if (!frontmatterMatch) { + const cleanContent = content.replace(/^---[\s\S]*?---\s*\n*/g, '').trim() + return { frontmatter: {}, content: cleanContent || content } + } + + const [, frontmatterText] = frontmatterMatch + const frontmatterEnd = frontmatterMatch[0].length + const cleanContent = content.slice(frontmatterEnd).replace(/^---[\s\S]*?---\s*\n*/g, '').trim() + + const frontmatter: Frontmatter = {} + const lines = frontmatterText.split('\n') + let currentKey: string | undefined + let currentValue: SectionLinkListItem[] = [] + let currentItem: Partial | undefined + let inArray = false + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const lineIndent = line.match(/^(\s*)/)?.[1]?.length ?? 0 + + if (lineIndent === 0 && trimmed.includes(':')) { + if (inArray && currentKey) { + frontmatter[currentKey] = currentValue + currentValue = [] + inArray = false + } + + const colonMatch = trimmed.match(/^([^:]+):\s*(.*)$/) + if (colonMatch) { + const [, key, value] = colonMatch + currentKey = key.trim() + const trimmedValue = value.trim() + + if (trimmedValue === '') { + inArray = true + currentValue = [] + } else { + frontmatter[currentKey] = trimmedValue.replace(/^["']|["']$/g, '') + } + } + } else if (lineIndent === 2 && trimmed.startsWith('- ')) { + if (!inArray && currentKey) { + inArray = true + currentValue = [] + } + + if (currentItem && !currentValue.includes(currentItem as SectionLinkListItem)) { + currentValue.push(currentItem as SectionLinkListItem) + } + + const itemText = trimmed.slice(2).trim() + if (itemText.includes(':')) { + currentItem = {} + const parts = itemText.split(',').map(p => p.trim()) + for (const part of parts) { + const colonMatch = part.match(/^(\w+):\s*(.+)$/) + if (colonMatch) { + const [, key, value] = colonMatch + const sectionKey = key as keyof SectionLinkListItem + if (SECTION_LINK_KEYS.includes(sectionKey)) { + currentItem[sectionKey] = value.replace(/^["']|["']$/g, '') + } + } + } + } else { + currentValue.push({ title: itemText, link: '' }) + } + } else if (lineIndent >= 4 && currentItem && trimmed.includes(':')) { + const colonIndex = trimmed.indexOf(':') + if (colonIndex !== -1) { + const key = trimmed.substring(0, colonIndex).trim() + const value = trimmed.substring(colonIndex + 1).trim() + const sectionKey = key as keyof SectionLinkListItem + if (SECTION_LINK_KEYS.includes(sectionKey)) { + currentItem[sectionKey] = value === '' ? '' : value.replace(/^["']|["']$/g, '') + } + } + } + } + } + + if (currentItem && inArray && !currentValue.includes(currentItem as SectionLinkListItem)) { + currentValue.push(currentItem as SectionLinkListItem) + } + if (inArray && currentKey) { + frontmatter[currentKey] = currentValue + } + + return { frontmatter, content: cleanContent || content } +} + +export const stringifyFrontmatter = (frontmatter: Frontmatter, content: string) => { + const lines: string[] = [] + + for (const [key, value] of Object.entries(frontmatter)) { + if (Array.isArray(value)) { + lines.push(`${key}:`) + for (const item of value) { + if (typeof item === 'object' && item) { + const link = item as SectionLinkListItem + lines.push(` - title: ${link.title ?? ''}`) + if (link.link) lines.push(` link: ${link.link}`) + if ('description' in link && link.description !== undefined && link.description !== null && String(link.description).trim() !== '') { + lines.push(` description: ${String(link.description)}`) + } + } else { + lines.push(` - ${item}`) + } + } + } else if (value) { + const strValue = String(value) + const needsQuotes = strValue.includes(':') || (strValue.includes(' ') && !strValue.startsWith('"')) || strValue === '' + const escaped = strValue.replace(/"/g, '\\"') + lines.push(`${key}: ${needsQuotes ? `"${escaped}"` : strValue}`) + } + } + + return `---\n${lines.join('\n')}\n---\n\n${content}` +} diff --git a/src/.vitepress/plugins/utils/links.ts b/src/.vitepress/plugins/utils/links.ts new file mode 100644 index 0000000..121e745 --- /dev/null +++ b/src/.vitepress/plugins/utils/links.ts @@ -0,0 +1,120 @@ +import type { SidebarItem } from './types' +import { getPageDescription } from './descriptions' +import { normalizeLink } from './path-utils' +import { SectionLinkListItem } from '../../theme/components/SectionLinkList/SectionLinkList.types' + +export const mergeSectionLinks = ( + existingLinks: SectionLinkListItem[], + newLinks: SectionLinkListItem[] +) => { + const existingLinksMap = new Map() + const result: SectionLinkListItem[] = [] + const processedLinks = new Set() + + for (const existingLink of existingLinks) { + if (existingLink.link) { + const normalizedLink = normalizeLink(existingLink.link) + if (!existingLinksMap.has(normalizedLink)) { + existingLinksMap.set(normalizedLink, existingLink) + } + } + } + + for (const newLink of newLinks) { + if (newLink.link) { + const normalizedLink = normalizeLink(newLink.link) + + if (!processedLinks.has(normalizedLink)) { + processedLinks.add(normalizedLink) + + if (existingLinksMap.has(normalizedLink)) { + const existingLink = existingLinksMap.get(normalizedLink)! + result.push({ ...existingLink, title: newLink.title }) + } else { + result.push(newLink) + } + } + } + } + + for (const existingLink of existingLinks) { + if (existingLink.link) { + const normalizedLink = normalizeLink(existingLink.link) + if (!processedLinks.has(normalizedLink)) { + processedLinks.add(normalizedLink) + result.push({ ...existingLink }) + } + } + } + + return result +} + +export const extractTopLevelLinks = ( + items: SidebarItem[], + currentIndexPath: string, + srcDir: string +) => { + const links: SectionLinkListItem[] = [] + const normalizedCurrentPath = normalizeLink(currentIndexPath) + + for (const item of items) { + if (item.link) { + const normalizedItemPath = normalizeLink(item.link) + if (normalizedItemPath === normalizedCurrentPath) { + continue + } + + links.push({ + title: item.text || '', + link: item.link, + description: getPageDescription(item.link, srcDir) + }) + } + } + + return links +} + +const findItemsRecursive = ( + itemsList: SidebarItem[], + pagePath: string, + srcDir: string +): SectionLinkListItem[] => { + const normalizedPagePath = normalizeLink(pagePath) + const links: SectionLinkListItem[] = [] + + for (const item of itemsList) { + if (item.link) { + const normalizedItemPath = normalizeLink(item.link) + + if (normalizedItemPath === normalizedPagePath && item.items && Array.isArray(item.items) && item.items.length > 0) { + for (const subItem of item.items) { + if (subItem.link) { + links.push({ + title: subItem.text || '', + link: subItem.link, + description: getPageDescription(subItem.link, srcDir) + }) + } + } + return links + } + } + + if (item.items && Array.isArray(item.items)) { + const found = findItemsRecursive(item.items, pagePath, srcDir) + if (found.length > 0) { + return found + } + } + } + + return links +} + +export const extractItemsForPage = ( + items: SidebarItem[], + pagePath: string, + srcDir: string +) => findItemsRecursive(items, pagePath, srcDir) diff --git a/src/.vitepress/plugins/utils/path-utils.ts b/src/.vitepress/plugins/utils/path-utils.ts new file mode 100644 index 0000000..7e113d6 --- /dev/null +++ b/src/.vitepress/plugins/utils/path-utils.ts @@ -0,0 +1,6 @@ +export const normalizeLink = (link: string): string => + !link ? '' : link + .replace(/^\/+/, '') + .replace(/\.md$/, '') + .replace(/\\/g, '/') + .trim() diff --git a/src/.vitepress/plugins/utils/types.ts b/src/.vitepress/plugins/utils/types.ts new file mode 100644 index 0000000..b9e18ba --- /dev/null +++ b/src/.vitepress/plugins/utils/types.ts @@ -0,0 +1,12 @@ +import { SectionLinkListItem } from "../../theme/components/SectionLinkList/SectionLinkList.types" + +export type SidebarItem = { + text: string + link?: string + items?: SidebarItem[] +} + +export type Frontmatter = { + section_links?: SectionLinkListItem[] + [key: string]: any +} diff --git a/src/compute/compute-how-to/compute-connect-index.md b/src/compute/compute-how-to/compute-connect-index.md index 705a298..5398f64 100644 --- a/src/compute/compute-how-to/compute-connect-index.md +++ b/src/compute/compute-how-to/compute-connect-index.md @@ -7,8 +7,7 @@ section_links: link: /compute/compute-how-to/compute-connect-inside.md description: Подключиться к виртуальной машине по SSH с помощью ключевой пары по внутреннему IP-адресу через джамп-хост - title: Подключение по SSH по логину и паролю - link: /compute/compute-how-to/compute-connect-inside.md - description: Подключиться к виртуальной машине по SSH с помощью логина и пароля ---- + link: /compute/compute-how-to/compute-connect-pwd.md +--- -# Подключение к ВМ +# Подключение к ВМ \ No newline at end of file diff --git a/src/compute/index.md b/src/compute/index.md index 3dccaa4..7e24d8a 100644 --- a/src/compute/index.md +++ b/src/compute/index.md @@ -3,6 +3,8 @@ section_links: - title: Обзор сервиса link: /compute/compute-overview-index.md description: Обзор сервиса, решаемые задачи, характеристики оборудования + - title: Быстрый старт + link: /compute/compute-getting-started.md - title: Виртуальные машины link: /compute/compute-how-to/compute-index.md description: Создание виртуальной машины и подключение к ней, управление виртуальной машиной @@ -14,9 +16,11 @@ section_links: description: Резервирование, назначение IP-адреса виртуальной машине, удаление IP-адресов - title: Группы размещения link: /compute/compute-how-to/compute-affinity.md - description: Создание правил размещения виртуальных машин на физических хостах, управление группами размещения ---- + description: Создание правил размещения виртуальных машин на физических хостах, управление группами размещения + - title: Сети + link: /compute/compute-how-to/compute-network/compute-network-index.md +--- # Виртуальные машины -Сервис **Виртуальные машины** предоставляет пользователям виртуальные машины. +Сервис **Виртуальные машины** предоставляет пользователям виртуальные машины. \ No newline at end of file