Improve linter messages in UI (#4351)

This commit is contained in:
Robert Kaussow 2024-11-11 23:34:20 +01:00 committed by GitHub
parent 04e8309e60
commit a1193f0fb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 122 additions and 77 deletions

View file

@ -78,7 +78,7 @@ func (l *Linter) lintFile(config *WorkflowConfig) error {
var linterErr error var linterErr error
if len(config.Workflow.Steps.ContainerList) == 0 { if len(config.Workflow.Steps.ContainerList) == 0 {
linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing steps section", config.File, "steps", false)) linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing `steps` section", config.File, "steps", false))
} }
if err := l.lintCloneSteps(config); err != nil { if err := l.lintCloneSteps(config); err != nil {
@ -123,7 +123,7 @@ func (l *Linter) lintCloneSteps(config *WorkflowConfig) error {
if !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) { if !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) {
linterErr = multierr.Append(linterErr, linterErr = multierr.Append(linterErr,
newLinterError( newLinterError(
"Specified clone image does not match allow list, netrc will not be injected", "Specified clone image does not match allow list, netrc is not injected",
config.File, fmt.Sprintf("clone.%s", container.Name), true), config.File, fmt.Sprintf("clone.%s", container.Name), true),
) )
} }
@ -173,7 +173,7 @@ func (l *Linter) lintImage(config *WorkflowConfig, c *types.Container, area stri
func (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error { func (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error {
// lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918 // lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918
if utils.MatchImage(c.Image, "plugins/docker", "plugins/gcr", "plugins/ecr", "woodpeckerci/plugin-docker-buildx") { if utils.MatchImage(c.Image, "plugins/docker", "plugins/gcr", "plugins/ecr", "woodpeckerci/plugin-docker-buildx") {
msg := fmt.Sprintf("The formerly privileged plugin '%s' is no longer privileged by default, if required, add it to WOODPECKER_PLUGINS_PRIVILEGED", c.Image) msg := fmt.Sprintf("The formerly privileged plugin `%s` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`", c.Image)
// check first if user did not add them back // check first if user did not add them back
if l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) { if l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) {
return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), false) return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), false)
@ -191,16 +191,16 @@ func (l *Linter) lintSettings(config *WorkflowConfig, c *types.Container, field
return nil return nil
} }
if len(c.Commands) != 0 { if len(c.Commands) != 0 {
return newLinterError("Cannot configure both commands and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), false) return newLinterError("Cannot configure both `commands` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
} }
if len(c.Entrypoint) != 0 { if len(c.Entrypoint) != 0 {
return newLinterError("Cannot configure both entrypoint and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), false) return newLinterError("Cannot configure both `entrypoint` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
} }
if len(c.Environment) != 0 { if len(c.Environment) != 0 {
return newLinterError("Should not configure both environment and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), true) return newLinterError("Should not configure both `environment` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
} }
if len(c.Secrets) != 0 { if len(c.Secrets) != 0 {
return newLinterError("Should not configure both secrets and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), true) return newLinterError("Should not configure both `secrets` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
} }
return nil return nil
} }
@ -210,32 +210,32 @@ func (l *Linter) lintTrusted(config *WorkflowConfig, c *types.Container, area st
errors := []string{} errors := []string{}
if !l.trusted.Security { if !l.trusted.Security {
if c.Privileged { if c.Privileged {
errors = append(errors, "Insufficient privileges to use privileged mode") errors = append(errors, "Insufficient trust level to use `privileged` mode")
} }
} }
if !l.trusted.Network { if !l.trusted.Network {
if len(c.DNS) != 0 { if len(c.DNS) != 0 {
errors = append(errors, "Insufficient privileges to use custom dns") errors = append(errors, "Insufficient trust level to use custom `dns`")
} }
if len(c.DNSSearch) != 0 { if len(c.DNSSearch) != 0 {
errors = append(errors, "Insufficient privileges to use dns_search") errors = append(errors, "Insufficient trust level to use `dns_search`")
} }
if len(c.ExtraHosts) != 0 { if len(c.ExtraHosts) != 0 {
errors = append(errors, "Insufficient privileges to use extra_hosts") errors = append(errors, "Insufficient trust level to use `extra_hosts`")
} }
if len(c.NetworkMode) != 0 { if len(c.NetworkMode) != 0 {
errors = append(errors, "Insufficient privileges to use network_mode") errors = append(errors, "Insufficient trust level to use `network_mode`")
} }
} }
if !l.trusted.Volumes { if !l.trusted.Volumes {
if len(c.Devices) != 0 { if len(c.Devices) != 0 {
errors = append(errors, "Insufficient privileges to use devices") errors = append(errors, "Insufficient trust level to use `devices`")
} }
if len(c.Volumes.Volumes) != 0 { if len(c.Volumes.Volumes) != 0 {
errors = append(errors, "Insufficient privileges to use volumes") errors = append(errors, "Insufficient trust level to use `volumes`")
} }
if len(c.Tmpfs) != 0 { if len(c.Tmpfs) != 0 {
errors = append(errors, "Insufficient privileges to use tmpfs") errors = append(errors, "Insufficient trust level to use `tmpfs`")
} }
} }
@ -279,7 +279,7 @@ func (l *Linter) lintDeprecations(config *WorkflowConfig) (err error) {
if len(container.Secrets) > 0 { if len(container.Secrets) > 0 {
err = multierr.Append(err, &errorTypes.PipelineError{ err = multierr.Append(err, &errorTypes.PipelineError{
Type: errorTypes.PipelineErrorTypeDeprecation, Type: errorTypes.PipelineErrorTypeDeprecation,
Message: "Secrets are deprecated, use environment with from_secret", Message: "Usage of `secrets` is deprecated, use `environment` with `from_secret`",
Data: errors.DeprecationErrorData{ Data: errors.DeprecationErrorData{
File: config.File, File: config.File,
Field: fmt.Sprintf("steps.%s.secrets", container.Name), Field: fmt.Sprintf("steps.%s.secrets", container.Name),
@ -328,7 +328,7 @@ func (l *Linter) lintBadHabits(config *WorkflowConfig) (err error) {
if field != "" { if field != "" {
err = multierr.Append(err, &errorTypes.PipelineError{ err = multierr.Append(err, &errorTypes.PipelineError{
Type: errorTypes.PipelineErrorTypeBadHabit, Type: errorTypes.PipelineErrorTypeBadHabit,
Message: "Please set an event filter for all steps or the whole workflow on all items of the when block", Message: "Set an event filter for all steps or the entire workflow on all items of the `when` block",
Data: errors.BadHabitErrorData{ Data: errors.BadHabitErrorData{
File: config.File, File: config.File,
Field: field, Field: field,

View file

@ -114,7 +114,7 @@ func TestLintErrors(t *testing.T) {
}{ }{
{ {
from: "", from: "",
want: "Invalid or missing steps section", want: "Invalid or missing `steps` section",
}, },
{ {
from: "steps: { build: { image: '' } }", from: "steps: { build: { image: '' } }",
@ -122,48 +122,48 @@ func TestLintErrors(t *testing.T) {
}, },
{ {
from: "steps: { build: { image: golang, privileged: true } }", from: "steps: { build: { image: golang, privileged: true } }",
want: "Insufficient privileges to use privileged mode", want: "Insufficient trust level to use `privileged` mode",
}, },
{ {
from: "steps: { build: { image: golang, dns: [ 8.8.8.8 ] } }", from: "steps: { build: { image: golang, dns: [ 8.8.8.8 ] } }",
want: "Insufficient privileges to use custom dns", want: "Insufficient trust level to use custom `dns`",
}, },
{ {
from: "steps: { build: { image: golang, dns_search: [ example.com ] } }", from: "steps: { build: { image: golang, dns_search: [ example.com ] } }",
want: "Insufficient privileges to use dns_search", want: "Insufficient trust level to use `dns_search`",
}, },
{ {
from: "steps: { build: { image: golang, devices: [ '/dev/tty0:/dev/tty0' ] } }", from: "steps: { build: { image: golang, devices: [ '/dev/tty0:/dev/tty0' ] } }",
want: "Insufficient privileges to use devices", want: "Insufficient trust level to use `devices`",
}, },
{ {
from: "steps: { build: { image: golang, extra_hosts: [ 'somehost:162.242.195.82' ] } }", from: "steps: { build: { image: golang, extra_hosts: [ 'somehost:162.242.195.82' ] } }",
want: "Insufficient privileges to use extra_hosts", want: "Insufficient trust level to use `extra_hosts`",
}, },
{ {
from: "steps: { build: { image: golang, network_mode: host } }", from: "steps: { build: { image: golang, network_mode: host } }",
want: "Insufficient privileges to use network_mode", want: "Insufficient trust level to use `network_mode`",
}, },
{ {
from: "steps: { build: { image: golang, volumes: [ '/opt/data:/var/lib/mysql' ] } }", from: "steps: { build: { image: golang, volumes: [ '/opt/data:/var/lib/mysql' ] } }",
want: "Insufficient privileges to use volumes", want: "Insufficient trust level to use `volumes`",
}, },
{ {
from: "steps: { build: { image: golang, network_mode: 'container:name' } }", from: "steps: { build: { image: golang, network_mode: 'container:name' } }",
want: "Insufficient privileges to use network_mode", want: "Insufficient trust level to use `network_mode`",
}, },
{ {
from: "steps: { build: { image: golang, settings: { test: 'true' }, commands: [ 'echo ja', 'echo nein' ] } }", from: "steps: { build: { image: golang, settings: { test: 'true' }, commands: [ 'echo ja', 'echo nein' ] } }",
want: "Cannot configure both commands and settings", want: "Cannot configure both `commands` and `settings`",
}, },
{ {
from: "steps: { build: { image: golang, settings: { test: 'true' }, entrypoint: [ '/bin/fish' ] } }", from: "steps: { build: { image: golang, settings: { test: 'true' }, entrypoint: [ '/bin/fish' ] } }",
want: "Cannot configure both entrypoint and settings", want: "Cannot configure both `entrypoint` and `settings`",
}, },
{ {
from: "steps: { build: { image: golang, settings: { test: 'true' }, environment: { 'TEST': 'true' } } }", from: "steps: { build: { image: golang, settings: { test: 'true' }, environment: { 'TEST': 'true' } } }",
want: "Should not configure both environment and settings", want: "Should not configure both `environment` and `settings`",
}, },
{ {
from: "{pipeline: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push } }", from: "{pipeline: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push } }",
@ -171,11 +171,11 @@ func TestLintErrors(t *testing.T) {
}, },
{ {
from: "{steps: { build: { image: plugins/docker, settings: { test: 'true' } } }, when: { branch: main, event: push } } }", from: "{steps: { build: { image: plugins/docker, settings: { test: 'true' } } }, when: { branch: main, event: push } } }",
want: "The formerly privileged plugin 'plugins/docker' is no longer privileged by default, if required, add it to WOODPECKER_PLUGINS_PRIVILEGED", want: "The formerly privileged plugin `plugins/docker` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`",
}, },
{ {
from: "{steps: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push }, clone: { git: { image: some-other/plugin-git:v1.1.0 } } }", from: "{steps: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push }, clone: { git: { image: some-other/plugin-git:v1.1.0 } } }",
want: "Specified clone image does not match allow list, netrc will not be injected", want: "Specified clone image does not match allow list, netrc is not injected",
}, },
} }
@ -209,11 +209,11 @@ func TestBadHabits(t *testing.T) {
}{ }{
{ {
from: "steps: { build: { image: golang } }", from: "steps: { build: { image: golang } }",
want: "Please set an event filter for all steps or the whole workflow on all items of the when block", want: "Set an event filter for all steps or the entire workflow on all items of the `when` block",
}, },
{ {
from: "when: [{branch: xyz}, {event: push}]\nsteps: { build: { image: golang } }", from: "when: [{branch: xyz}, {event: push}]\nsteps: { build: { image: golang } }",
want: "Please set an event filter for all steps or the whole workflow on all items of the when block", want: "Set an event filter for all steps or the entire workflow on all items of the `when` block",
}, },
} }

View file

@ -24,9 +24,11 @@
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"ansi_up": "^6.0.2", "ansi_up": "^6.0.2",
"dayjs": "^1.11.12", "dayjs": "^1.11.12",
"dompurify": "^3.2.0",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^15.0.0",
"node-emoji": "^2.1.3", "node-emoji": "^2.1.3",
"pinia": "^2.2.1", "pinia": "^2.2.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",

View file

@ -29,6 +29,9 @@ importers:
dayjs: dayjs:
specifier: ^1.11.12 specifier: ^1.11.12
version: 1.11.13 version: 1.11.13
dompurify:
specifier: ^3.2.0
version: 3.2.0
fuse.js: fuse.js:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
@ -38,6 +41,9 @@ importers:
lodash: lodash:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
marked:
specifier: ^15.0.0
version: 15.0.0
node-emoji: node-emoji:
specifier: ^2.1.3 specifier: ^2.1.3
version: 2.1.3 version: 2.1.3
@ -1310,6 +1316,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
dompurify@3.2.0:
resolution: {integrity: sha512-AMdOzK44oFWqHEi0wpOqix/fUNY707OmoeFDnbi3Q5I8uOpy21ufUA5cDJPr0bosxrflOVD/H2DMSvuGKJGfmQ==}
domutils@3.1.0: domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@ -1921,6 +1930,11 @@ packages:
markdown-table@3.0.3: markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
marked@15.0.0:
resolution: {integrity: sha512-0mouKmBROJv/WSHJBPZZyYofUgawMChnD5je/g+aOBXsHDjb/IsnTQj7mnhQZu+qPJmRQ0ecX3mLGEUm3BgwYA==}
engines: {node: '>= 18'}
hasBin: true
mdast-util-find-and-replace@3.0.1: mdast-util-find-and-replace@3.0.1:
resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==}
@ -4055,6 +4069,8 @@ snapshots:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
dompurify@3.2.0: {}
domutils@3.1.0: domutils@3.1.0:
dependencies: dependencies:
dom-serializer: 2.0.0 dom-serializer: 2.0.0
@ -4766,6 +4782,8 @@ snapshots:
markdown-table@3.0.3: {} markdown-table@3.0.3: {}
marked@15.0.0: {}
mdast-util-find-and-replace@3.0.1: mdast-util-find-and-replace@3.0.1:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4

View file

@ -3,9 +3,9 @@
:href="`${docsUrl}`" :href="`${docsUrl}`"
:title="$t('documentation_for', { topic })" :title="$t('documentation_for', { topic })"
target="_blank" target="_blank"
class="text-wp-link-100 hover:text-wp-link-200 cursor-pointer mt-1" class="text-wp-link-100 hover:text-wp-link-200 cursor-pointer"
> >
<Icon name="question" class="!w-4 !h-4" /> <Icon name="question" class="!w-5 !h-5" />
</a> </a>
</template> </template>

View file

@ -0,0 +1,18 @@
<template>
<span v-html="contentHTML" />
</template>
<script setup lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { computed } from 'vue';
const props = defineProps<{
content: string;
}>();
const contentHTML = computed<string>(() => {
const dirtyHTML = marked.parse(props.content);
return DOMPurify.sanitize(dirtyHTML as string, { USE_PROFILES: { html: true } });
});
</script>

View file

@ -14,9 +14,9 @@
</template> </template>
<template #description> <template #description>
<i18n-t keypath="repo.settings.general.pipeline_path.desc" tag="p" class="text-sm text-wp-text-alt-100"> <i18n-t keypath="repo.settings.general.pipeline_path.desc" tag="p" class="text-sm text-wp-text-alt-100">
<span class="code-box-inline px-1">{{ $t('repo.settings.general.pipeline_path.desc_path_example') }}</span> <span class="code-box-inline">{{ $t('repo.settings.general.pipeline_path.desc_path_example') }}</span>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="code-box-inline px-1">/</span> <span class="code-box-inline">/</span>
</i18n-t> </i18n-t>
</template> </template>
</InputField> </InputField>

View file

@ -147,6 +147,7 @@ body,
white-space: pre-wrap; white-space: pre-wrap;
} }
.code-box-inline { .code-box-inline,
@apply bg-wp-code-200 rounded-md text-wp-code-text-100; code:not(pre > code) {
@apply bg-wp-code-200 rounded-md text-wp-code-text-100 px-1 py-px;
} }

View file

@ -1,37 +1,47 @@
<template> <template>
<Panel> <Panel>
<div class="grid justify-center gap-x-4 text-left grid-3-1"> <div class="flex flex-col gap-y-4">
<template v-for="(error, i) in pipeline!.errors" :key="i"> <template v-for="(error, _index) in pipeline!.errors" :key="_index">
<Icon <div>
name="attention" <div class="grid grid-cols-[minmax(10rem,auto),3fr]">
class="flex-shrink-0 my-1" <span class="flex items-center gap-x-2">
:class="{ <Icon
'text-wp-state-warn-100': error.is_warning, name="attention"
'text-wp-state-error-100': !error.is_warning, class="flex-shrink-0 my-1"
}" :class="{
/> 'text-wp-state-warn-100': error.is_warning,
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> 'text-wp-state-error-100': !error.is_warning,
<span>[{{ error.type }}]</span> }"
<span />
v-if="isLinterError(error) || isDeprecationError(error) || isBadHabitError(error)" <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
class="whitespace-nowrap" <span>
> <code>{{ error.type }}</code>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> </span>
<span v-if="error.data?.file" class="font-bold">{{ error.data?.file }}: </span> </span>
<span>{{ error.data?.field }}</span> <span
</span> v-if="isLinterError(error) || isDeprecationError(error) || isBadHabitError(error)"
<span v-else /> class="flex items-center gap-x-2 whitespace-nowrap"
<a >
v-if="isDeprecationError(error) || isBadHabitError(error)" <span>
:href="error.data?.docs" <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
target="_blank" <span v-if="error.data?.file" class="font-bold">{{ error.data?.file }}: </span>
class="underline col-span-full col-start-2 md:col-span-auto md:col-start-auto" <span>{{ error.data?.field }}</span>
> </span>
{{ error.message }} <DocsLink
</a> v-if="isDeprecationError(error) || isBadHabitError(error)"
<span v-else class="col-span-full col-start-2 md:col-span-auto md:col-start-auto"> :topic="error.data?.field || ''"
{{ error.message }} :url="error.data?.docs || ''"
</span> />
</span>
<span v-else />
</div>
<div class="grid grid-cols-[minmax(10rem,auto),4fr] col-start-2">
<span />
<span>
<RenderMarkdown :content="error.message" />
</span>
</div>
</div>
</template> </template>
</div> </div>
</Panel> </Panel>
@ -40,7 +50,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { inject, type Ref } from 'vue'; import { inject, type Ref } from 'vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
import Icon from '~/components/atomic/Icon.vue'; import Icon from '~/components/atomic/Icon.vue';
import RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';
import Panel from '~/components/layout/Panel.vue'; import Panel from '~/components/layout/Panel.vue';
import type { Pipeline, PipelineError } from '~/lib/api/types'; import type { Pipeline, PipelineError } from '~/lib/api/types';
@ -63,9 +75,3 @@ function isBadHabitError(error: PipelineError): error is PipelineError<{ file?:
return error.type === 'bad_habit'; return error.type === 'bad_habit';
} }
</script> </script>
<style scoped>
.grid-3-1 {
grid-template-columns: auto auto auto 1fr;
}
</style>