713 lines
20 KiB
Vue
713 lines
20 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。
|
||
*/
|
||
import { Promotion, Refresh, Search } from '@element-plus/icons-vue'
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import StatusPill from '../components/StatusPill.vue'
|
||
import { usePlatformStore } from '../stores/platform'
|
||
import type { EnvironmentStatus, HealthState, ProjectConfig, ProjectPermissionLevel } from '../types/devops'
|
||
|
||
const platform = usePlatformStore()
|
||
const diagnosticsLoading = ref(false)
|
||
const searchKeyword = ref('')
|
||
const readinessFilter = ref<ProjectReadinessState | ''>('')
|
||
const permissionFilter = ref<ProjectPermissionLevel | ''>('')
|
||
|
||
type ProjectReadinessState = 'ready' | 'attention' | 'blocked' | 'readonly'
|
||
|
||
const readinessOptions: Array<{ label: string; value: ProjectReadinessState }> = [
|
||
{ label: '可发布', value: 'ready' },
|
||
{ label: '需关注', value: 'attention' },
|
||
{ label: '有阻断', value: 'blocked' },
|
||
{ label: '只读', value: 'readonly' },
|
||
]
|
||
|
||
const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [
|
||
{ label: '构建权限', value: 'build' },
|
||
{ label: '只读权限', value: 'read' },
|
||
]
|
||
|
||
const filteredProjects = computed(() => {
|
||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||
|
||
return platform.projects.filter((project) => {
|
||
if (readinessFilter.value && projectReadinessState(project) !== readinessFilter.value) {
|
||
return false
|
||
}
|
||
|
||
if (permissionFilter.value && platform.projectPermissionLevel(project.key) !== permissionFilter.value) {
|
||
return false
|
||
}
|
||
|
||
if (!keyword) {
|
||
return true
|
||
}
|
||
|
||
return [
|
||
project.name,
|
||
project.key,
|
||
project.serviceType,
|
||
project.repository,
|
||
project.defaultBranch,
|
||
project.tagPolicy,
|
||
project.permissionLevelName,
|
||
]
|
||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||
.some((value) => value.toLowerCase().includes(keyword))
|
||
})
|
||
})
|
||
|
||
const projectOverview = computed(() => {
|
||
const ready = platform.projects.filter((project) => projectReadinessState(project) === 'ready').length
|
||
const attention = platform.projects.filter((project) => projectReadinessState(project) === 'attention').length
|
||
const blocked = platform.projects.filter((project) => projectReadinessState(project) === 'blocked').length
|
||
const readOnly = platform.projects.filter((project) => projectReadinessState(project) === 'readonly').length
|
||
|
||
return [
|
||
{ key: 'total', label: '纳管项目', value: platform.projects.length, tone: 'neutral' },
|
||
{ key: 'ready', label: '可发布', value: ready, tone: 'success' },
|
||
{ key: 'attention', label: '需关注', value: attention, tone: attention > 0 ? 'warning' : 'neutral' },
|
||
{ key: 'blocked', label: '有阻断', value: blocked, tone: blocked > 0 ? 'danger' : 'neutral' },
|
||
{ key: 'readonly', label: '只读项目', value: readOnly, tone: readOnly > 0 ? 'muted' : 'neutral' },
|
||
]
|
||
})
|
||
|
||
const hasActiveFilters = computed(() => Boolean(
|
||
searchKeyword.value.trim() ||
|
||
readinessFilter.value ||
|
||
permissionFilter.value,
|
||
))
|
||
|
||
onMounted(() => {
|
||
void refreshProjectDiagnostics()
|
||
})
|
||
|
||
async function refreshProjectDiagnostics() {
|
||
diagnosticsLoading.value = true
|
||
|
||
try {
|
||
await Promise.all([
|
||
platform.loadProjectJenkinsDiagnostics(),
|
||
platform.loadProjectGiteaDiagnostics(),
|
||
])
|
||
} finally {
|
||
diagnosticsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function lastBuildText(env: EnvironmentStatus): string {
|
||
if (!env.jenkinsLastBuild?.number) {
|
||
return '-'
|
||
}
|
||
|
||
if (env.jenkinsLastBuild.building) {
|
||
return `#${env.jenkinsLastBuild.number} 运行中`
|
||
}
|
||
|
||
return `#${env.jenkinsLastBuild.number} ${env.jenkinsLastBuild.result || 'UNKNOWN'}`
|
||
}
|
||
|
||
function commitText(project: ProjectConfig): string {
|
||
const commit = project.giteaLatestCommit
|
||
|
||
if (!commit?.sha) {
|
||
return '-'
|
||
}
|
||
|
||
const title = commit.message.split('\n')[0] || '无提交说明'
|
||
return `${commit.sha.slice(0, 8)} · ${title}`
|
||
}
|
||
|
||
function branchTagText(project: ProjectConfig): string {
|
||
if (project.giteaBranchCount === undefined && project.giteaTagCount === undefined) {
|
||
return '尚未诊断'
|
||
}
|
||
|
||
const branchCount = project.giteaBranchCount ?? 0
|
||
const tagCount = project.giteaTagCount ?? 0
|
||
|
||
return `${branchCount} branches · ${tagCount} tags`
|
||
}
|
||
|
||
function webhookCountText(project: ProjectConfig): string {
|
||
if (
|
||
project.giteaWebhookHookCount === undefined &&
|
||
project.giteaWebhookActivePushHookCount === undefined
|
||
) {
|
||
return '尚未诊断'
|
||
}
|
||
|
||
return `${project.giteaWebhookActivePushHookCount ?? 0}/${project.giteaWebhookHookCount ?? 0} 个 active push hook`
|
||
}
|
||
|
||
function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): string {
|
||
const events = hook.events.length > 0 ? hook.events.join(', ') : '无事件'
|
||
const branch = hook.branchFilter ? ` · ${hook.branchFilter}` : ''
|
||
const matched = hook.matchesExpectedRelay ? ' · relay 匹配' : ''
|
||
const active = hook.active ? '启用' : '停用'
|
||
|
||
return `#${hook.id} ${hook.type} · ${active} · ${events}${branch}${matched}`
|
||
}
|
||
|
||
function projectReadinessState(project: ProjectConfig): ProjectReadinessState {
|
||
if (projectHasBlockingIssue(project)) {
|
||
return 'blocked'
|
||
}
|
||
|
||
if (projectHasWarningIssue(project)) {
|
||
return 'attention'
|
||
}
|
||
|
||
if (!platform.canBuildProject(project.key)) {
|
||
return 'readonly'
|
||
}
|
||
|
||
return 'ready'
|
||
}
|
||
|
||
function projectReadinessLabel(project: ProjectConfig): string {
|
||
const state = projectReadinessState(project)
|
||
|
||
if (state === 'ready') {
|
||
return '可发布'
|
||
}
|
||
|
||
if (state === 'blocked') {
|
||
return '有阻断'
|
||
}
|
||
|
||
if (state === 'readonly') {
|
||
return '只读'
|
||
}
|
||
|
||
return '需关注'
|
||
}
|
||
|
||
function projectReadinessTagType(
|
||
project: ProjectConfig,
|
||
): 'success' | 'warning' | 'danger' | 'info' {
|
||
const state = projectReadinessState(project)
|
||
|
||
if (state === 'ready') {
|
||
return 'success'
|
||
}
|
||
|
||
if (state === 'blocked') {
|
||
return 'danger'
|
||
}
|
||
|
||
return state === 'attention' ? 'warning' : 'info'
|
||
}
|
||
|
||
function projectReadinessSummary(project: ProjectConfig): string {
|
||
const issues = projectIssueSummaries(project)
|
||
|
||
if (issues.length > 0) {
|
||
return issues[0]
|
||
}
|
||
|
||
if (!platform.canBuildProject(project.key)) {
|
||
return '当前账号只有只读权限'
|
||
}
|
||
|
||
return 'Gitea、Jenkins、Webhook 和通知链路均未发现异常'
|
||
}
|
||
|
||
function projectIssueSummaries(project: ProjectConfig): string[] {
|
||
const issues: string[] = []
|
||
|
||
appendHealthIssue(issues, 'Gitea 仓库', project.giteaStatus, project.giteaMessage)
|
||
appendHealthIssue(issues, 'Webhook / Relay', project.giteaWebhookStatus, project.giteaWebhookMessage)
|
||
|
||
for (const env of project.environments) {
|
||
appendHealthIssue(issues, `${env.label} Jenkins`, env.jenkinsStatus, env.jenkinsMessage)
|
||
}
|
||
|
||
appendHealthIssue(issues, '通知链路', project.integrations.wecom)
|
||
|
||
return issues
|
||
}
|
||
|
||
function appendHealthIssue(
|
||
issues: string[],
|
||
label: string,
|
||
state: HealthState,
|
||
message?: string,
|
||
) {
|
||
if (state === 'healthy') {
|
||
return
|
||
}
|
||
|
||
const stateText = state === 'warning' ? '待配置' : state === 'offline' ? '离线' : '异常'
|
||
issues.push(`${label}${message ? `:${message}` : `:${stateText}`}`)
|
||
}
|
||
|
||
function projectHasBlockingIssue(project: ProjectConfig): boolean {
|
||
return projectHealthStates(project).some((state) => state === 'error' || state === 'offline')
|
||
}
|
||
|
||
function projectHasWarningIssue(project: ProjectConfig): boolean {
|
||
return projectHealthStates(project).some((state) => state === 'warning')
|
||
}
|
||
|
||
function projectHealthStates(project: ProjectConfig): HealthState[] {
|
||
return [
|
||
project.giteaStatus,
|
||
project.giteaWebhookStatus,
|
||
project.integrations.wecom,
|
||
...project.environments.map((env) => env.jenkinsStatus),
|
||
]
|
||
}
|
||
|
||
function resetProjectFilters() {
|
||
searchKeyword.value = ''
|
||
readinessFilter.value = ''
|
||
permissionFilter.value = ''
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-shell">
|
||
<div class="page-header">
|
||
<div>
|
||
<h1>项目配置</h1>
|
||
<p>展示后端返回的项目、环境、Jenkins Job、Gitea 仓库和通知通道绑定关系。</p>
|
||
</div>
|
||
<div class="toolbar">
|
||
<ElTag type="info" round>后端配置源</ElTag>
|
||
<ElButton
|
||
:icon="Refresh"
|
||
type="primary"
|
||
plain
|
||
:loading="diagnosticsLoading"
|
||
@click="refreshProjectDiagnostics"
|
||
>
|
||
刷新项目诊断
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="project-overview-grid">
|
||
<article
|
||
v-for="item in projectOverview"
|
||
:key="item.key"
|
||
class="project-overview-card"
|
||
:class="`tone-${item.tone}`"
|
||
>
|
||
<span>{{ item.label }}</span>
|
||
<strong>{{ item.value }}</strong>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<h2>项目清单</h2>
|
||
<span>{{ filteredProjects.length }} / {{ platform.projects.length }} 个项目</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="project-filter-bar">
|
||
<ElInput
|
||
v-model="searchKeyword"
|
||
clearable
|
||
:prefix-icon="Search"
|
||
placeholder="搜索项目 / 仓库 / 默认分支"
|
||
class="project-search-input"
|
||
/>
|
||
<ElSelect v-model="readinessFilter" clearable placeholder="发布态">
|
||
<ElOption
|
||
v-for="option in readinessOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</ElSelect>
|
||
<ElSelect v-model="permissionFilter" clearable placeholder="权限">
|
||
<ElOption
|
||
v-for="option in permissionOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</ElSelect>
|
||
<ElButton :disabled="!hasActiveFilters" @click="resetProjectFilters">
|
||
重置筛选
|
||
</ElButton>
|
||
</div>
|
||
<ElTable :data="filteredProjects" row-key="key" empty-text="没有符合条件的项目">
|
||
<ElTableColumn type="expand">
|
||
<template #default="{ row }">
|
||
<div class="env-config">
|
||
<div class="project-diagnostic-card">
|
||
<div class="env-config-head">
|
||
<strong>Gitea 仓库诊断</strong>
|
||
<StatusPill :state="row.giteaStatus" />
|
||
</div>
|
||
<dl>
|
||
<dt>仓库状态</dt>
|
||
<dd>
|
||
<StatusPill :state="row.giteaStatus" />
|
||
<span class="diagnostic-message">{{ row.giteaMessage }}</span>
|
||
</dd>
|
||
<dt>公开地址</dt>
|
||
<dd>
|
||
<a
|
||
v-if="row.giteaWebUrl"
|
||
:href="row.giteaWebUrl"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
{{ row.giteaWebUrl }}
|
||
</a>
|
||
<span v-else class="mono">{{ row.repository }}</span>
|
||
</dd>
|
||
<dt>默认分支</dt>
|
||
<dd>{{ row.giteaDefaultBranch || row.defaultBranch }}</dd>
|
||
<dt>分支 / Tag</dt>
|
||
<dd>{{ branchTagText(row) }}</dd>
|
||
<dt>默认分支 commit</dt>
|
||
<dd class="mono">{{ commitText(row) }}</dd>
|
||
<dt>Webhook / Relay</dt>
|
||
<dd>
|
||
<StatusPill :state="row.giteaWebhookStatus" />
|
||
<span class="diagnostic-message">{{ row.giteaWebhookMessage }}</span>
|
||
</dd>
|
||
<dt>Relay 目标</dt>
|
||
<dd class="mono">{{ row.giteaWebhookExpectedRelayTarget || '-' }}</dd>
|
||
<dt>Hook 数量</dt>
|
||
<dd>{{ webhookCountText(row) }}</dd>
|
||
<dt>Hook 明细</dt>
|
||
<dd>
|
||
<div v-if="row.giteaWebhookHooks.length > 0" class="webhook-hook-list">
|
||
<div
|
||
v-for="hook in row.giteaWebhookHooks"
|
||
:key="hook.id"
|
||
class="webhook-hook-item"
|
||
>
|
||
<span>{{ webhookHookText(hook) }}</span>
|
||
<small class="mono">{{ hook.target || '-' }}</small>
|
||
</div>
|
||
</div>
|
||
<span v-else>-</span>
|
||
</dd>
|
||
</dl>
|
||
</div>
|
||
<div v-for="env in row.environments" :key="env.name" class="env-config-card">
|
||
<div class="env-config-head">
|
||
<strong>{{ env.label }}</strong>
|
||
<StatusPill :state="env.status" />
|
||
</div>
|
||
<dl>
|
||
<dt>分支策略</dt>
|
||
<dd>{{ env.branchPolicy }}</dd>
|
||
<dt>Jenkins Job</dt>
|
||
<dd class="mono">{{ env.jenkinsJob }}</dd>
|
||
<dt>Job 状态</dt>
|
||
<dd>
|
||
<StatusPill :state="env.jenkinsStatus" />
|
||
<span class="diagnostic-message">{{ env.jenkinsMessage }}</span>
|
||
</dd>
|
||
<dt>最近构建</dt>
|
||
<dd>
|
||
<a
|
||
v-if="env.jenkinsLastBuild?.url"
|
||
:href="env.jenkinsLastBuild.url"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
{{ lastBuildText(env) }}
|
||
</a>
|
||
<span v-else>{{ lastBuildText(env) }}</span>
|
||
</dd>
|
||
<dt>通知通道</dt>
|
||
<dd>{{ env.wecomRobot }}</dd>
|
||
<dt>最近发布</dt>
|
||
<dd>
|
||
<div v-if="env.lastRunId !== '-'" class="latest-release">
|
||
<div class="latest-release-head">
|
||
<StatusPill :state="env.status" />
|
||
<RouterLink
|
||
class="run-link mono"
|
||
:to="{ name: 'runs', query: { runId: env.lastRunId } }"
|
||
>
|
||
{{ env.lastRunId }}
|
||
</RouterLink>
|
||
</div>
|
||
<small>
|
||
{{ env.version }} · {{ env.lastActor }} · {{ env.lastReleasedAt }}
|
||
</small>
|
||
</div>
|
||
<span v-else>暂无真实发布记录</span>
|
||
</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn prop="name" label="项目" min-width="150" />
|
||
<ElTableColumn label="发布态" min-width="190">
|
||
<template #default="{ row }">
|
||
<div class="readiness-cell">
|
||
<ElTag :type="projectReadinessTagType(row)" effect="light" round>
|
||
{{ projectReadinessLabel(row) }}
|
||
</ElTag>
|
||
<small>{{ projectReadinessSummary(row) }}</small>
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn label="权限" width="120">
|
||
<template #default="{ row }">
|
||
<ElTag :type="platform.canBuildProject(row.key) ? 'success' : 'info'" effect="plain" round>
|
||
{{ platform.canBuildProject(row.key) ? '构建' : '只读' }}
|
||
</ElTag>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn prop="serviceType" label="类型" min-width="130" />
|
||
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
|
||
<template #default="{ row }">
|
||
<span class="mono">{{ row.repository }}</span>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn prop="defaultBranch" label="默认分支" width="120" />
|
||
<ElTableColumn prop="tagPolicy" label="Tag 策略" min-width="220" />
|
||
<ElTableColumn label="集成状态" width="280">
|
||
<template #default="{ row }">
|
||
<div class="status-tags">
|
||
<StatusPill :state="row.integrations.jenkins" text="Jenkins" />
|
||
<StatusPill :state="row.integrations.gitea" text="Gitea" />
|
||
<StatusPill :state="row.integrations.wecom" text="通知" />
|
||
<StatusPill :state="row.integrations.llm" text="LLM" />
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn label="操作" width="170" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="project-actions">
|
||
<RouterLink
|
||
v-slot="{ navigate }"
|
||
custom
|
||
:to="{ name: 'releases', query: { projectKey: row.key } }"
|
||
>
|
||
<ElButton
|
||
:icon="Promotion"
|
||
link
|
||
type="primary"
|
||
:disabled="!platform.canBuildProject(row.key)"
|
||
@click="navigate"
|
||
>
|
||
发布
|
||
</ElButton>
|
||
</RouterLink>
|
||
<RouterLink :to="{ name: 'runs', query: { projectKey: row.key } }">
|
||
<ElButton link type="primary">运行记录</ElButton>
|
||
</RouterLink>
|
||
</div>
|
||
</template>
|
||
</ElTableColumn>
|
||
</ElTable>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.project-overview-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.project-overview-card {
|
||
min-width: 0;
|
||
padding: 12px 14px;
|
||
border: 1px solid #d8e2ef;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.project-overview-card span {
|
||
display: block;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.project-overview-card strong {
|
||
display: block;
|
||
margin-top: 6px;
|
||
color: #0f172a;
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.project-overview-card.tone-success {
|
||
border-color: #bbf7d0;
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
.project-overview-card.tone-warning {
|
||
border-color: #fde68a;
|
||
background: #fffbeb;
|
||
}
|
||
|
||
.project-overview-card.tone-danger {
|
||
border-color: #fecaca;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.project-overview-card.tone-muted {
|
||
border-color: #e2e8f0;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.project-filter-bar {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1.4fr) 150px 150px max-content;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.project-search-input {
|
||
min-width: 0;
|
||
}
|
||
|
||
.env-config {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
padding: 14px 46px;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.env-config-card {
|
||
padding: 14px;
|
||
border: 1px solid #e1e8f2;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.project-diagnostic-card {
|
||
grid-column: 1 / -1;
|
||
padding: 14px;
|
||
border: 1px solid #d7e5ff;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.env-config-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
dl {
|
||
display: grid;
|
||
grid-template-columns: 100px minmax(0, 1fr);
|
||
gap: 8px 12px;
|
||
margin: 0;
|
||
color: #596578;
|
||
font-size: 13px;
|
||
}
|
||
|
||
dt {
|
||
color: #7b8798;
|
||
}
|
||
|
||
dd {
|
||
margin: 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.status-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.readiness-cell {
|
||
display: grid;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.readiness-cell small {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.project-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.diagnostic-message {
|
||
margin-left: 8px;
|
||
color: #596578;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.latest-release {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.latest-release-head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.latest-release small {
|
||
color: #7b8798;
|
||
}
|
||
|
||
.webhook-hook-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.webhook-hook-item {
|
||
display: grid;
|
||
gap: 3px;
|
||
}
|
||
|
||
.webhook-hook-item small {
|
||
color: #7b8798;
|
||
}
|
||
|
||
.run-link {
|
||
color: #2563eb;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.run-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
@media (max-width: 1080px) {
|
||
.project-overview-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
|
||
.project-filter-bar {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.project-overview-grid,
|
||
.project-filter-bar {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.env-config {
|
||
grid-template-columns: 1fr;
|
||
padding: 12px;
|
||
}
|
||
}
|
||
</style>
|